// Components for the Parallel Editions site.
// All component-scope styles are inline or scoped via classNames in the main CSS.

const { useState, useMemo, useEffect, useRef } = React;

// GoatCounter custom event. `event: true` files it under the Events tab.
function trackEvent(path, title) {
  if (typeof window === "undefined") return;
  window.goatcounter?.count?.({ path, title, event: true });
}

/* ───────── Typographic cover ─────────
   When we don't have a real cover image, render a typographic one.
   Each book has a small palette to differentiate them. The bottom band
   reflects the edition's direction (e.g. "ES-EN BILINGUAL"). */
function TypographicCover({ book, edition }) {
  const [bg, accent, text] = book.palette;
  const isLight = (hex) => {
    const r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16);
    return (r*0.299 + g*0.587 + b*0.114) > 160;
  };
  const inkOnBg = isLight(bg) ? "#1a1a1a" : "#f5ebe0";
  const bandLabel = edition
    ? `${edition.target.toUpperCase()} + ${edition.support.toUpperCase()} · BILINGUAL`
    : "BILINGUAL";

  return (
    <div className="cover" style={{ background: bg, color: inkOnBg }}>
      <div className="cover-classic">
        <div className="cover-band-top" style={{ background: accent, color: isLight(accent) ? "#1a1a1a" : "#f5ebe0" }}>
          PARALLEL EDITIONS
        </div>
        <div className="cover-classic-body">
          <div className="cover-classic-title">{book.title}</div>
          <div className="cover-classic-rule" style={{ background: accent }}></div>
          <div className="cover-classic-author">{book.author}</div>
        </div>
        <div className="cover-band-bot" style={{ borderColor: accent }}>
          <span>{bandLabel}</span>
          <span>{book.year}</span>
        </div>
      </div>
    </div>
  );
}

/* ───────── Photo cover ───────── */
function PhotoCover({ src, title, author }) {
  // `src` is the .jpg path from data.js (optionally `?v=<hash>` for cache busting);
  // covers-webp.sh emits a .webp sibling. The browser picks WebP when it can and
  // falls back to the JPEG otherwise.
  const q = src.includes("?") ? src.slice(src.indexOf("?")) : "";
  const jpg = src.split("?")[0];
  const webp = jpg.replace(/\.jpg$/, ".webp") + q;
  return (
    <div className="cover cover-photo">
      <picture>
        <source srcSet={webp} type="image/webp" />
        <img
          src={jpg + q}
          alt={`${title}, ${author}`}
          loading="lazy"
          decoding="async"
          width="400"
          height="600"
        />
      </picture>
    </div>
  );
}

/* ───────── Samples manifest ─────────
   Fetches samples/manifest.json once on mount. Returns a Map of
   editionId ("book-target-support") → file path, or null while loading. */
function useSamplesManifest() {
  const [manifest, setManifest] = useState(null);
  useEffect(() => {
    fetch("samples/manifest.json")
      .then(r => (r.ok ? r.json() : {}))
      .then(json => {
        const m = new Map();
        for (const [key, entry] of Object.entries(json)) {
          m.set(key, entry.file);
        }
        setManifest(m);
      })
      .catch(() => setManifest(new Map()));
  }, []);
  return manifest;
}

/* ───────── Sample reader modal ─────────
   Renders a paginated epub.js reader for the sample associated with one
   edition. Closes on backdrop click, ESC, or the close button. */
function SampleReaderModal({ book, edition, samplePath, onClose }) {
  const viewportRef = useRef(null);
  const renditionRef = useRef(null);
  const cardRef = useRef(null);
  const [loading, setLoading] = useState(true);
  const [progress, setProgress] = useState({ page: 0, total: 0 });
  const [atStart, setAtStart] = useState(true);
  const [atEnd, setAtEnd] = useState(false);

  const targetLang = LANG_BY_CODE[edition.target];
  const supportLang = LANG_BY_CODE[edition.support];
  const placeholder = isPlaceholder(edition);

  useEffect(() => {
    if (!viewportRef.current || !window.ePub) return;
    const book_ = window.ePub(samplePath);
    const rendition = book_.renderTo(viewportRef.current, {
      width: "100%",
      height: "100%",
      flow: "paginated",
      spread: "none",
      allowScriptedContent: false,
    });
    renditionRef.current = rendition;

    rendition.themes.default({
      "body": {
        "font-family": "'Newsreader', Georgia, serif !important",
        "color": "#17191d !important",
        "background": "#f4f2ec !important",
        "padding": "0 8px !important",
      },
      ".pair-container": {
        "padding": "0.4em 0",
        "border-bottom": "1px solid rgba(60, 60, 60, 0.14)",
      },
      ".target-text p": { "margin": "0 0 0.4em" },
      ".source-text p": {
        "margin": "0 0 0.6em",
        "color": "#383b42",
        "font-style": "italic",
        "opacity": "0.9",
      },
      "h1": {
        "font-family": "'Schibsted Grotesk', Helvetica, sans-serif",
        "font-weight": "600",
        "letter-spacing": "-0.02em",
      },
    });

    rendition.display().then(() => setLoading(false));

    book_.ready.then(() => book_.locations.generate(1600)).then(() => {
      const total = book_.locations.length();
      setProgress(p => ({ ...p, total }));
    });

    rendition.on("relocated", (loc) => {
      setAtStart(loc.atStart === true);
      setAtEnd(loc.atEnd === true);
      const pct = loc.start.percentage;
      const total = book_.locations.length();
      if (total) {
        setProgress({ page: Math.max(1, Math.round(pct * total)), total });
      }
    });

    return () => {
      try { rendition.destroy(); } catch {}
      try { book_.destroy(); } catch {}
    };
  }, [samplePath]);

  useEffect(() => {
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";

    // Remember what was focused so we can restore it on close, then move
    // focus into the dialog (the card itself — see tabIndex={-1} below).
    const prevFocus = document.activeElement;
    cardRef.current?.focus();

    // Focusable chrome controls inside the dialog. The epub.js content lives
    // in a cross-origin-ish iframe we deliberately don't reach into; trapping
    // the card's own controls is enough to keep keyboard focus contained.
    const focusablesIn = (root) => Array.from(
      root.querySelectorAll('button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])')
    );

    const onKey = (e) => {
      if (e.key === "Escape") { onClose(); return; }
      if (e.key === "ArrowLeft") { renditionRef.current?.prev(); return; }
      if (e.key === "ArrowRight") { renditionRef.current?.next(); return; }
      if (e.key === "Tab" && cardRef.current) {
        const items = focusablesIn(cardRef.current);
        if (items.length === 0) { e.preventDefault(); return; }
        const first = items[0];
        const last = items[items.length - 1];
        const active = document.activeElement;
        // Wrap at the ends, and pull focus back in if it has escaped the card.
        if (e.shiftKey) {
          if (active === first || !cardRef.current.contains(active)) {
            e.preventDefault();
            last.focus();
          }
        } else if (active === last || !cardRef.current.contains(active)) {
          e.preventDefault();
          first.focus();
        }
      }
    };
    window.addEventListener("keydown", onKey);
    return () => {
      document.body.style.overflow = prevOverflow;
      window.removeEventListener("keydown", onKey);
      if (prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
    };
  }, [onClose]);

  const onBackdropClick = (e) => {
    if (e.target === e.currentTarget) onClose();
  };

  return (
    <div className="reader-overlay" onClick={onBackdropClick} role="dialog" aria-modal="true" aria-label={`${book.title} sample`}>
      <div className="reader-card" ref={cardRef} tabIndex={-1}>
        <header className="reader-head">
          <div>
            <h2 className="reader-head-title">{book.title}</h2>
            <span className="reader-head-meta">
              {edition.target === "en"
                ? `Sample · English edition for ${supportLang?.name} speakers`
                : `Sample · ${targetLang?.name} (${targetLang?.native}) with ${supportLang?.name} helper`}
            </span>
          </div>
          <button className="reader-close" onClick={onClose} aria-label="Close sample reader">×</button>
        </header>

        <div className="reader-body">
          <button
            className="reader-nav"
            onClick={() => renditionRef.current?.prev()}
            disabled={atStart}
            aria-label="Previous page"
          >‹</button>
          <div className="reader-viewport" ref={viewportRef}>
            {loading && <div className="reader-loading">Loading sample…</div>}
          </div>
          <button
            className="reader-nav"
            onClick={() => renditionRef.current?.next()}
            disabled={atEnd}
            aria-label="Next page"
          >›</button>
        </div>

        <footer className="reader-foot">
          <span className="reader-progress">
            {progress.total
              ? `${progress.page} / ${progress.total}`
              : "Sample · 1 chapter"}
          </span>
          {placeholder ? (
            <a href="#requests" className="reader-cta reader-cta--ghost" onClick={onClose}>
              Coming soon, request a notification
            </a>
          ) : (
            <a href={edition.url} target="_blank" rel="noopener" className="reader-cta">
              Get the full edition on Kindle →
            </a>
          )}
        </footer>
      </div>
    </div>
  );
}

/* ───────── Book card ─────────
   One card = one book. Direction chips switch the active edition;
   cover, buy link, and sample button all follow it. */
function BookCard({ book, editions, selection, samplesAvailable, onRequestSample }) {
  const selKey = selection ? `${selection.target}-${selection.support}` : "all";
  // A manual chip pick is tagged with the filter context it was made under,
  // so changing the language filter invalidates it — no effect-sync needed.
  const [manual, setManual] = useState(null); // { selKey, id } | null

  // Spanish always takes precedence: if the book has an ES·EN edition it is
  // the default (even unpublished); a live edition leads only when there is
  // no Spanish edition in the visible list (e.g. published-only mode).
  const isEs = e => e.target === "es" && e.support === "en";
  const active =
    (manual && manual.selKey === selKey
      && editions.find(e => editionId(book, e) === manual.id))
    || (selection && editions.find(e =>
          e.target === selection.target && e.support === selection.support))
    || editions.find(isEs)
    || editions.find(e => !isPlaceholder(e))
    || editions[0];

  // ES·EN leads the chip row; the rest keep catalog order.
  const chips = [...editions].sort((a, b) => (isEs(a) ? 0 : 1) - (isEs(b) ? 0 : 1));

  const targetLang = LANG_BY_CODE[active.target];
  const supportLang = LANG_BY_CODE[active.support];
  const eid = editionId(book, active);
  const samplePath = samplesAvailable?.get(eid) || null;
  const placeholder = isPlaceholder(active);

  return (
    <article className="book-card">
      <div className="book-card-cover-wrap">
        {active.cover
          ? <PhotoCover src={active.cover} title={book.title} author={book.author} />
          : <TypographicCover book={book} edition={active} />}
      </div>
      <div className="book-card-body">
        <h3 className="book-card-title">{book.title}</h3>
        <div className="book-card-meta">
          <span className="book-card-author">{book.author}</span>
          <span className="book-card-dot">·</span>
          <span className="book-card-year">{book.year}</span>
        </div>

        <div className="edition-chips" role="group" aria-label={`Editions of ${book.title}`}>
          {chips.map(e => {
            const id = editionId(book, e);
            const isActive = e === active;
            const tl = LANG_BY_CODE[e.target], sl = LANG_BY_CODE[e.support];
            return (
              <button
                key={id}
                className={`edition-chip${isActive ? " is-active" : ""}${isPlaceholder(e) ? " edition-chip--soon" : ""}`}
                aria-pressed={isActive}
                title={e.target === "en"
                  ? `English edition for ${sl?.name} speakers${isPlaceholder(e) ? " — coming soon" : ""}`
                  : `Learn ${tl?.name} — ${sl?.name} helper${isPlaceholder(e) ? " — coming soon" : ""}`}
                onClick={() => {
                  setManual({ selKey, id });
                  trackEvent(`edition-chip-${id}`, `Chip: ${book.title} (${e.target}→${e.support})`);
                }}
              >
                <span className="edition-chip-code">{e.target.toUpperCase()}</span>
                <span className="edition-chip-sep">·</span>
                <span className="edition-chip-support">{e.support.toUpperCase()}</span>
              </button>
            );
          })}
        </div>

        <div className="book-card-direction">
          {active.target === "en"
            ? <>English edition · {supportLang?.name} helper</>
            : <>Learn {targetLang?.name} · {supportLang?.name} helper</>}
        </div>

        <div className="book-card-actions">
          {placeholder ? (
            <a href="#requests" className="edition-cta edition-cta--ghost">
              Coming soon
            </a>
          ) : (
            <a
              href={active.url}
              target="_blank"
              rel="noopener"
              className="edition-cta"
              title={`Buy on ${targetLang?.market}`}
              onClick={() => trackEvent(
                `kindle-click-${eid}`,
                `Kindle: ${book.title} (${active.target}→${active.support})`
              )}
            >
              Get on Kindle →
            </a>
          )}
          {samplePath && (
            <button
              className="read-sample-btn"
              onClick={() => {
                trackEvent(
                  `sample-open-${eid}`,
                  `Sample: ${book.title} (${active.target}→${active.support})`
                );
                onRequestSample(book, active, samplePath);
              }}
              aria-label={`Read a sample of ${book.title} (${targetLang?.name} edition)`}
            >
              Read sample
            </button>
          )}
        </div>
      </div>
    </article>
  );
}

/* ───────── Language filter ─────────
   Two grouped rows of audience-prose pills. Each pill represents one
   (target, support) direction cohort. `value` is either null (all editions)
   or { target, support }; `onChange` receives the same shape. */
function LanguageFilter({ value, onChange, books }) {
  const { learnersOfX, englishLearners } = useMemo(() => {
    const learnersOfX = {};   // target -> book count, for XX-EN editions
    const englishLearners = {}; // support -> book count, for EN-XX editions
    books.forEach(({ editions }) => {
      const dirs = new Set(editions.map(e => `${e.target}-${e.support}`));
      for (const d of dirs) {
        const [target, support] = d.split("-");
        if (target !== "en" && support === "en") {
          learnersOfX[target] = (learnersOfX[target] || 0) + 1;
        } else if (target === "en" && support !== "en") {
          englishLearners[support] = (englishLearners[support] || 0) + 1;
        }
      }
    });
    return { learnersOfX, englishLearners };
  }, [books]);

  const isActive = (target, support) =>
    value && value.target === target && value.support === support;

  const pill = (lang, target, support, count) => (
    <button
      key={`${target}-${support}`}
      className={`lang-filter-btn ${isActive(target, support) ? "is-active" : ""}`}
      onClick={() => {
        trackEvent(`lang-filter-${target}-${support}`, `Filter: ${target}→${support}`);
        onChange({ target, support });
      }}
    >
      <span className="lang-filter-name">{lang.name}</span>
      <span className="lang-filter-native">{lang.native}</span>
      <span className="lang-filter-count">{count}</span>
    </button>
  );

  return (
    <div className="lang-filter-groups">
      <div className="lang-filter-group">
        <div className="lang-filter-label">English speakers learning…</div>
        <div className="lang-filter">
          {LANGUAGES
            .filter(l => l.code !== "en" && learnersOfX[l.code])
            .map(l => pill(l, l.code, "en", learnersOfX[l.code]))}
        </div>
      </div>

      <div className="lang-filter-group">
        <div className="lang-filter-label">Learning English, native language…</div>
        <div className="lang-filter">
          {LANGUAGES
            .filter(l => l.code !== "en" && englishLearners[l.code])
            .map(l => pill(l, "en", l.code, englishLearners[l.code]))}
        </div>
      </div>

      <div className="lang-filter">
        <button
          className={`lang-filter-btn ${value === null ? "is-active" : ""}`}
          onClick={() => onChange(null)}
        >
          <span className="lang-filter-name">All books</span>
          <span className="lang-filter-count">{books.length}</span>
        </button>
      </div>
    </div>
  );
}

/* ───────── Availability filter ─────────
   A single on/off facet: "Published to Amazon" (live editions only, the
   default) vs "All editions" (includes not-yet-published placeholders). */
function PublishedFilter({ value, onChange, editions }) {
  const liveCount = editions.filter(({ edition }) => !isPlaceholder(edition)).length;

  const pill = (label, active, count, onClick) => (
    <button
      className={`lang-filter-btn ${active ? "is-active" : ""}`}
      onClick={onClick}
    >
      <span className="lang-filter-name">{label}</span>
      <span className="lang-filter-count">{count}</span>
    </button>
  );

  return (
    <div className="lang-filter-group">
      <div className="lang-filter-label">Availability</div>
      <div className="lang-filter">
        {pill("Published to Amazon", value, liveCount, () => {
          trackEvent("filter-published-only", "Filter: Published to Amazon");
          onChange(true);
        })}
        {pill("All editions", !value, editions.length, () => {
          trackEvent("filter-published-all", "Filter: All editions");
          onChange(false);
        })}
      </div>
    </div>
  );
}

/* ───────── Pitch / feature strip ───────── */
function PitchStrip() {
  const items = [
    {
      n: "01",
      title: "Paragraph-level parallel text",
      body: "Each translated paragraph sits directly beside the original, so you never lose the thread between the two languages."
    },
    {
      n: "02",
      title: "End-of-chapter vocabulary notes",
      body: "Fifteen to twenty entries per chapter: idioms, period vocabulary, and culturally loaded expressions a dictionary alone won't explain."
    },
    {
      n: "03",
      title: "Kindle dictionary auto-switching",
      body: "Every paragraph carries a language tag, so long-pressing a word opens the dictionary for that language and not the other one."
    },
    {
      n: "04",
      title: "A guide to studying with the book",
      body: "Three ways to read it: target-language first for B2 and up, native-language first for B1 to B2, and shadow reading."
    },
  ];
  return (
    <section className="pitch">
      <div className="pitch-head">
        <h2 className="pitch-title">
          What's different about these editions
        </h2>
        <p className="pitch-lede">
          Every title is a complete bilingual edition, set paragraph by paragraph rather than in chapter blocks or sentence fragments. It is built for upper-intermediate and advanced readers (CEFR B2 to C1) who are ready to move from textbooks to real books.
        </p>
      </div>
      <ol className="pitch-grid">
        {items.map(it => (
          <li key={it.n} className="pitch-item">
            <div className="pitch-n">{it.n}</div>
            <div className="pitch-item-body">
              <h3 className="pitch-item-title">{it.title}</h3>
              <p className="pitch-item-text">{it.body}</p>
            </div>
          </li>
        ))}
      </ol>
    </section>
  );
}

/* ───────── Hero ───────── */
function Hero() {
  return (
    <section className="hero">
      <div className="hero-eyebrow">
        <span className="hero-eyebrow-rule"></span>
        <span>A library of bilingual classics</span>
      </div>
      <h1 className="hero-title">
        Read the classics<br/>
        {/* nbsp keeps "side by side" from splitting across the wrap. */}
        <em className="hero-title-em">in two languages side&nbsp;by&nbsp;side.</em>
      </h1>
      <p className="hero-sub">
        Paragraph-by-paragraph parallel editions of public-domain literature, for English speakers learning a language and for anyone learning English. Pick the direction that suits you, on Kindle.
      </p>
      <div className="hero-actions">
        <a href="#catalog" className="btn btn-primary">Browse the catalog</a>
        <a href="#pitch" className="btn btn-ghost">How they work →</a>
      </div>
      <a href="daily/" className="hero-daily" onClick={() => trackEvent("daily/hero-click")}>
        <span className="hero-daily-new">New</span> - translate one sentence a day, and get it graded in our daily challenge book club →
      </a>
      <div className="hero-langs">
        {LANGUAGES.map(l => (
          <span key={l.code}>
            <span className="hero-langs-native">{l.native}</span>
            <span className="hero-langs-code">{l.code.toUpperCase()}</span>
          </span>
        ))}
      </div>
    </section>
  );
}

/* ───────── Catalog ───────── */
function Catalog() {
  const [selection, setSelection] = useState(null); // { target, support } | null
  const [publishedOnly, setPublishedOnly] = useState(false); // default: All editions
  const [sort, setSort] = useState("title");
  const [query, setQuery] = useState("");
  const [reader, setReader] = useState(null); // { book, edition, samplePath } when open
  const samplesAvailable = useSamplesManifest();

  // Flat edition list — kept for the heading and the availability counts.
  const allEditions = useMemo(
    () => BOOKS.flatMap(b => b.editions.map(edition => ({ book: b, edition }))),
    []
  );

  // One entry per book, with the editions visible under the availability facet.
  const visibleBooks = useMemo(() =>
    BOOKS.map(book => ({
      book,
      editions: publishedOnly ? book.editions.filter(e => !isPlaceholder(e)) : book.editions,
    })).filter(({ editions }) => editions.length > 0),
    [publishedOnly]
  );

  const searched = useMemo(() => {
    const q = query.trim().toLowerCase();
    if (!q) return visibleBooks;
    return visibleBooks.filter(({ book }) =>
      book.title.toLowerCase().includes(q) || book.author.toLowerCase().includes(q));
  }, [visibleBooks, query]);

  const filtered = useMemo(() => {
    let list = selection
      ? searched.filter(({ editions }) => editions.some(e =>
          e.target === selection.target && e.support === selection.support))
      : searched.slice();
    // Kindle availability sorts first in every view, then the chosen sort
    // within each group. Under a language filter, "available" means that
    // direction's edition is live; otherwise Spanish takes precedence —
    // books live in ES·EN rank above books live only in other directions.
    const live = ({ editions }) => {
      if (selection) {
        return editions.some(e =>
          e.target === selection.target && e.support === selection.support && !isPlaceholder(e)) ? 0 : 1;
      }
      if (editions.some(e => e.target === "es" && e.support === "en" && !isPlaceholder(e))) return 0;
      return editions.some(e => !isPlaceholder(e)) ? 1 : 2;
    };
    const cmp =
      sort === "year" ? (a, b) => a.book.year - b.book.year
      : sort === "author" ? (a, b) => a.book.author.localeCompare(b.book.author)
      : (a, b) => a.book.title.localeCompare(b.book.title);
    list.sort((a, b) => live(a) - live(b) || cmp(a, b));
    return list;
  }, [searched, selection, sort]);

  return (
    <section id="catalog" className="catalog">
      <div className="catalog-head">
        <div className="catalog-head-left">
          <h2 className="catalog-title">{BOOKS.length} classics. {LANGUAGES.length} languages. {allEditions.length} editions.</h2>
        </div>
        <div className="catalog-head-right">
          <input
            type="search"
            className="catalog-search"
            value={query}
            onChange={e => setQuery(e.target.value)}
            placeholder="Search title or author…"
            aria-label="Search by title or author"
          />
          <label className="catalog-sort">
            <span>Sort</span>
            <select value={sort} onChange={e => setSort(e.target.value)}>
              <option value="title">A–Z by title</option>
              <option value="author">A–Z by author</option>
              <option value="year">By publication year</option>
            </select>
          </label>
        </div>
      </div>

      <PublishedFilter value={publishedOnly} onChange={setPublishedOnly} editions={allEditions} />
      <LanguageFilter value={selection} onChange={setSelection} books={searched} />

      <div className="catalog-resultline">
        {selection
          ? selection.target === "en"
            ? <>Showing <strong>{filtered.length}</strong> books for <strong>{LANG_BY_CODE[selection.support].name}</strong> speakers learning English</>
            : <>Showing <strong>{filtered.length}</strong> books for English speakers learning <strong>{LANG_BY_CODE[selection.target].name}</strong></>
          : <>Showing all <strong>{filtered.length}</strong> books</>}
        {query.trim() && <> matching “<strong>{query.trim()}</strong>”</>}
        {publishedOnly && <> · published editions only</>}
      </div>

      <div className="catalog-grid">
        {filtered.map(({ book, editions }) => (
          <BookCard
            key={book.id}
            book={book}
            editions={editions}
            selection={selection}
            samplesAvailable={samplesAvailable}
            onRequestSample={(b, ed, samplePath) => setReader({ book: b, edition: ed, samplePath })}
          />
        ))}
      </div>

      {reader && (
        <SampleReaderModal
          book={reader.book}
          edition={reader.edition}
          samplePath={reader.samplePath}
          onClose={() => setReader(null)}
        />
      )}
    </section>
  );
}

/* ───────── Classrooms / group discounts ─────────
   Quiet band between the catalog and the form: some editions are already
   used in language classes; teachers can ask for a group discount. */
function ClassroomBand({ onAsk }) {
  const ask = () => {
    trackEvent("classroom/ask", "Group discount CTA");
    onAsk?.();
  };
  return (
    <section id="classroom" className="classroom">
      <div className="classroom-inner">
        <div className="requests-eyebrow">For teachers and schools</div>
        <h2 className="requests-title">Already read in classrooms.</h2>
        <p className="requests-lede">
          Some of our editions are already used in language-learning classes. If you teach and want a set for your class or school, we offer great group discount offers. Tell us which book, which direction, and roughly how many students.
        </p>
        <div className="classroom-actions">
          <button className="btn btn-primary" onClick={ask}>Ask about a group discount</button>
        </div>
      </div>
    </section>
  );
}

/* ───────── Reader requests / feedback form ───────── */
function RequestsForm({ endpoint, presetKind }) {
  const [status, setStatus] = useState("idle"); // idle | submitting | submitted | error
  const [kind, setKind] = useState("");

  // Let the classroom band (and #requests-* deep links) pre-select a category.
  useEffect(() => {
    if (presetKind) setKind(presetKind);
  }, [presetKind]);

  async function onSubmit(e) {
    e.preventDefault();
    const form = e.currentTarget;
    setStatus("submitting");
    try {
      const res = await fetch(endpoint, {
        method: "POST",
        headers: { Accept: "application/json" },
        body: new FormData(form),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      setStatus("submitted");
      form.reset();
      setKind("");
    } catch (err) {
      setStatus("error");
    }
  }

  return (
    <section id="requests" className="requests">
      <div className="requests-head">
        <h2 className="requests-title">Tell us what to publish next.</h2>
        <p className="requests-lede">
          Want a classic we haven't done yet? Or a language pair or direction we don't offer? A feature you wish the books had, or a problem with one you bought? Send a note and it comes straight to us. Teaching a class? Ask about a group discount.
        </p>
      </div>

      {status === "submitted" ? (
        <div className="requests-thanks" role="status">
          <div className="requests-thanks-mark">✓</div>
          <h3>Thanks, we read every one.</h3>
          <p>If you left an email, we'll reply when we have something useful to say.</p>
        </div>
      ) : (
        <form className="requests-form" onSubmit={onSubmit} noValidate={false}>
          <input type="hidden" name="_subject" value="Reader request — parallel.pub" />
          {/* Honeypot — hidden from humans, irresistible to bots. */}
          <input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" className="field-honeypot" aria-hidden="true" />

          <div className="field-row">
            <label className="field">
              <span className="field-label">Your name <span className="field-opt">(optional)</span></span>
              <input type="text" name="name" autoComplete="name" maxLength={100} />
            </label>
            <label className="field">
              <span className="field-label">Email <span className="field-opt">(optional, if you want a reply)</span></span>
              <input type="email" name="_replyto" autoComplete="email" maxLength={200} />
            </label>
          </div>

          <label className="field">
            <span className="field-label">What's this about?</span>
            <select name="kind" required value={kind} onChange={(e) => setKind(e.target.value)}>
              <option value="" disabled>Choose one…</option>
              <option value="group-discount">Group discount for a class or school</option>
              <option value="book">Book request: a classic we haven't published</option>
              <option value="translation">Language pair or direction we don't yet offer</option>
              <option value="feature">Feature for the books</option>
              <option value="problem">Problem with a book</option>
              <option value="other">Something else</option>
            </select>
          </label>

          <label className="field">
            <span className="field-label">Your message</span>
            <textarea
              name="message"
              required
              minLength={20}
              maxLength={4000}
              rows={6}
              placeholder={{
                "group-discount": "Tell us which book and direction, roughly how many students, and where you teach.",
              }[kind] || "Tell us which book, which direction, or what's on your mind. Specifics help."}
            />
          </label>

          <div className="requests-actions">
            <button type="submit" className="requests-submit" disabled={status === "submitting"}>
              {status === "submitting" ? "Sending…" : "Send your request"}
            </button>
            {status === "error" && (
              <span className="requests-error" role="alert">
                Something went wrong. Try again, or email <a href="mailto:stanislav.sp@300consulting.fi">stanislav.sp@300consulting.fi</a>.
              </span>
            )}
          </div>
        </form>
      )}
    </section>
  );
}

/* ───────── Mailing list ─────────
   A desktop invite modal plus a permanent form in the footer. Signup is a
   same-origin POST to /api/subscribe; the function proxies to Kit, which owns
   double opt-in and unsubscribe. The free edition is claimed by replying to
   the confirmation email — nothing here is tied to leaving a review. */
const NL_SUBSCRIBED_KEY = "pe-newsletter-subscribed";     // "1", permanent
const NL_OPTOUT_KEY = "pe-newsletter-optout";             // "1", permanent
const NL_DISMISSED_AT_KEY = "pe-newsletter-dismissed-at"; // epoch ms
const NL_SHOWN_COUNT_KEY = "pe-newsletter-shown-count";   // integer

const NL_REARM_DAYS = 14;      // a soft dismiss is "not now", not "never"
const NL_MAX_IMPRESSIONS = 6;  // courtesy backstop for people who never answer
const NL_DELAY_MS = 25000;
const NL_RETRY_MS = 15000;     // another dialog was open — look again shortly

function nlGet(key) {
  try { return localStorage.getItem(key); } catch { return null; }
}
function nlSet(key, value) {
  try { localStorage.setItem(key, value); } catch {}
}
function nlSubscribed() { return !!nlGet(NL_SUBSCRIBED_KEY); }
function nlMarkSubscribed() { nlSet(NL_SUBSCRIBED_KEY, "1"); }
function nlMarkDismissed() { nlSet(NL_DISMISSED_AT_KEY, String(Date.now())); }
function nlMarkOptedOut() { nlSet(NL_OPTOUT_KEY, "1"); }
function nlBumpShown() {
  nlSet(NL_SHOWN_COUNT_KEY, String(Number(nlGet(NL_SHOWN_COUNT_KEY) || "0") + 1));
}

/* Storage unavailable (private mode) reads as "never nag" — the footer form is
   always there, so nobody loses the ability to subscribe. */
function nlInviteAllowed() {
  try {
    if (!window.localStorage) return false;
    if (nlGet(NL_SUBSCRIBED_KEY) || nlGet(NL_OPTOUT_KEY)) return false;
    if (Number(nlGet(NL_SHOWN_COUNT_KEY) || "0") >= NL_MAX_IMPRESSIONS) return false;
    const at = Number(nlGet(NL_DISMISSED_AT_KEY) || "0");
    if (at && Date.now() - at < NL_REARM_DAYS * 86400000) return false;
    return true;
  } catch { return false; }
}

/* Sends the signup to Kit from the visitor's own browser.

   This cannot run on the server. Kit scores the submitting IP and answers
   `status:"quarantined"` for datacenter addresses — verified in production,
   where the identical request from a residential IP came back `"success"`.
   Posting from the browser is also exactly what Kit's own embed does.

   The endpoint replies HTTP 200 even when it rejects the submission, so the
   `status` field is the only thing worth reading. */
async function postToKit({ url, params }) {
  let res;
  try {
    res = await fetch(url, {
      method: "POST",
      // Both headers are CORS-safelisted, so this stays a simple request.
      headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
      body: new URLSearchParams(params).toString(),
    });
  } catch {
    throw new Error("Couldn't reach the mailing list — check your connection and try again.");
  }
  const body = await res.json().catch(() => null);
  if (body && body.status === "success") return;
  if (body && body.status === "quarantined") {
    throw new Error("That signup was flagged for review. Email us and we'll add you by hand.");
  }
  const fields = (body && body.errors && body.errors.fields) || [];
  if (fields.includes("email_address")) throw new Error("That email address was rejected.");
  throw new Error("Something went wrong — please try again.");
}

/* Shared by the modal and the footer. Reports success upward via onDone so the
   modal can swap its own copy without owning the request. */
function SubscribeForm({ endpoint, source, autoFocus, onDone }) {
  const [status, setStatus] = useState("idle"); // idle | submitting | done | error
  const [error, setError] = useState("");
  const inputRef = useRef(null);
  const doneRef = useRef(null);

  useEffect(() => { if (autoFocus) inputRef.current?.focus(); }, [autoFocus]);

  // On success the whole form is replaced, so whatever had focus (the submit
  // button) is unmounted and focus falls to <body>. Move it to the confirmation
  // instead — keeps keyboard users in place and gets the message announced.
  useEffect(() => { if (status === "done") doneRef.current?.focus(); }, [status]);

  async function onSubmit(e) {
    e.preventDefault();
    const form = e.currentTarget;
    const email = form.elements.email.value.trim();
    if (!email) return;
    setStatus("submitting");
    trackEvent(`newsletter/submit-${source}`, "Newsletter submit");
    try {
      const res = await fetch(endpoint, {
        method: "POST",
        headers: { "content-type": "application/json" },
        body: JSON.stringify({ email, source, _gotcha: form.elements._gotcha.value }),
      });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(data.error || "Something went wrong — please try again.");
      // Our endpoint validates and rate-limits, then hands back the submission
      // for Kit. It has to be sent from here, not from the function: Kit scores
      // the submitting IP and quarantines datacenter traffic.
      if (data.post) await postToKit(data.post);
      nlMarkSubscribed();
      setStatus("done");
      trackEvent(`newsletter/subscribed-${source}`, "Newsletter subscribed");
      onDone?.();
    } catch (err) {
      setError(err.message);
      setStatus("error");
      trackEvent(`newsletter/error-${source}`, "Newsletter error");
    }
  }

  if (status === "done") {
    return (
      <div className="subscribe-done" role="status" ref={doneRef} tabIndex={-1}>
        <span className="subscribe-mark" aria-hidden="true">✓</span>
        <p>
          Check your inbox and click the confirmation link. Then reply to that email with the
          edition you'd like and your Kindle address, and we'll send it over.
        </p>
      </div>
    );
  }

  return (
    <form className="subscribe-form" onSubmit={onSubmit}>
      {/* Honeypot — hidden from humans, irresistible to bots. */}
      <input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" className="field-honeypot" aria-hidden="true" />
      <div className="subscribe-row">
        <label className="field subscribe-field">
          <input
            ref={inputRef}
            type="email"
            name="email"
            required
            maxLength={254}
            autoComplete="email"
            inputMode="email"
            spellCheck="false"
            placeholder="you@example.com"
            aria-label="Your email address"
          />
        </label>
        <button type="submit" className="requests-submit" disabled={status === "submitting"}>
          {status === "submitting" ? "Sending…" : "Subscribe"}
        </button>
      </div>
      <p className="subscribe-consent">
        Confirm by email or unsubscribe from any message. <a href="/privacy.html">How we handle your data</a>.
      </p>
      {status === "error" && (
        <span className="requests-error" role="alert">
          {error} Or email <a href="mailto:stanislav.sp@300consulting.fi">stanislav.sp@300consulting.fi</a>.
        </span>
      )}
    </form>
  );
}

function NewsletterInvite({ endpoint }) {
  const [open, setOpen] = useState(false);
  const [subscribed, setSubscribed] = useState(false);
  const cardRef = useRef(null);
  const subscribedRef = useRef(false); // read by the ESC handler, which closes over one render

  // Closing without subscribing is "not now" — back in NL_REARM_DAYS.
  const dismiss = () => {
    if (!subscribedRef.current) {
      nlMarkDismissed();
      trackEvent("newsletter/dismiss", "Newsletter dismissed");
    }
    setOpen(false);
  };
  // The honest exit: gone for good, no dark pattern.
  const optOut = () => {
    nlMarkOptedOut();
    trackEvent("newsletter/optout", "Newsletter opted out");
    setOpen(false);
  };

  useEffect(() => {
    if (!nlInviteAllowed()) return;
    let timer = null;
    let retries = 0;
    const desktop = () =>
      window.matchMedia("(min-width: 768px) and (hover: hover) and (pointer: fine)").matches;
    // Never stack on the sample reader — it owns the screen and locks scroll.
    const busy = () =>
      document.body.style.overflow === "hidden" ||
      !!document.querySelector('[role="dialog"][aria-modal="true"]');

    const tryOpen = () => {
      if (!desktop() || !nlInviteAllowed()) return;
      if (busy()) {
        if (retries++ < 4) timer = setTimeout(tryOpen, NL_RETRY_MS);
        return;
      }
      nlBumpShown();
      setOpen(true);
      trackEvent("newsletter/shown", "Newsletter invite shown");
    };

    timer = setTimeout(tryOpen, NL_DELAY_MS);
    return () => clearTimeout(timer);
  }, []);

  useEffect(() => {
    if (!open) return;
    const prevFocus = document.activeElement;
    const prevOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    // Child effects run before parent ones, so SubscribeForm's autoFocus has
    // already put the caret in the email field — don't yank it back out.
    if (!cardRef.current?.contains(document.activeElement)) cardRef.current?.focus();

    const onKey = (e) => {
      if (e.key === "Escape") { dismiss(); return; }
      if (e.key === "Tab" && cardRef.current) {
        // Inputs and selects must be in this list or Tab escapes the dialog.
        const items = Array.from(
          cardRef.current.querySelectorAll(
            'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
          )
        );
        if (items.length === 0) { e.preventDefault(); return; }
        const first = items[0];
        const last = items[items.length - 1];
        const active = document.activeElement;
        if (e.shiftKey) {
          if (active === first || !cardRef.current.contains(active)) {
            e.preventDefault();
            last.focus();
          }
        } else if (active === last || !cardRef.current.contains(active)) {
          e.preventDefault();
          first.focus();
        }
      }
    };
    window.addEventListener("keydown", onKey);
    return () => {
      window.removeEventListener("keydown", onKey);
      document.body.style.overflow = prevOverflow;
      if (prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
    };
    // Deliberately keyed on `open` alone: adding `subscribed` would tear the
    // trap down mid-modal and yank focus back to the page on success. The Tab
    // handler re-queries the DOM each press, so it picks up the swapped form.
  }, [open]);

  if (!open) return null;

  return (
    <div
      className="subscribe-overlay"
      onClick={(e) => { if (e.target === e.currentTarget) dismiss(); }}
      role="dialog"
      aria-modal="true"
      aria-labelledby="subscribe-title"
    >
      <div className="subscribe-card" ref={cardRef} tabIndex={-1}>
        <button className="reader-close subscribe-close" onClick={dismiss} aria-label="Close">×</button>
        <span className="subscribe-kicker">From the publisher</span>
        <h2 className="subscribe-title" id="subscribe-title">Join our news list and pick a free edition.</h2>
        <p className="subscribe-body">
          We publish new bilingual editions continuously and we want to keep in touch, occasionally.
          Subscribe and we'll send you any edition of your choosing from the catalog, to your
          Kindle device.
        </p>
        <SubscribeForm
          endpoint={endpoint}
          source="modal"
          autoFocus
          onDone={() => { subscribedRef.current = true; setSubscribed(true); }}
        />
        {!subscribed && (
          <button className="subscribe-optout" onClick={optOut}>Don't show this again</button>
        )}
      </div>
    </div>
  );
}

/* ───────── Masthead + footer ───────── */

/* Daily challenge status — same-origin read of the keys daily/daily.jsx writes.
   pe-daily-history is { "YYYY-MM-DD": { es: "good", … } }, keyed on the local date. */
function dailyDoneToday() {
  try {
    const d = new Date(), p = n => String(n).padStart(2, "0");
    const today = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
    const hist = JSON.parse(localStorage.getItem("pe-daily-history") || "{}");
    return !!hist[today] && Object.keys(hist[today]).length > 0;
  } catch { return false; } // storage unavailable — show the open state
}

function Masthead() {
  const doneToday = dailyDoneToday();
  return (
    <div className="masthead-wrap">
      <header className="masthead" data-screen-label="Site masthead">
        <a href="#" className="masthead-brand">
          <span className="masthead-mark">P<span className="masthead-mark-amp">·</span>E</span>
          <span className="masthead-name">Parallel Editions</span>
        </a>
        <div className="masthead-actions">
          <a
            href="daily/"
            className={"masthead-daily" + (doneToday ? " is-done" : "")}
            onClick={() => trackEvent("daily/nav-click")}
          >
            <span className="masthead-daily-dot" aria-hidden="true">{doneToday ? "✓" : ""}</span>
            Daily challenge
          </a>
          <nav className="masthead-nav">
            <a href="#pitch">How they work</a>
            <a href="#classroom">For teachers</a>
            <a href="#requests">Requests</a>
          </nav>
          <a href="#catalog" className="masthead-cta">Browse →</a>
        </div>
      </header>
    </div>
  );
}

function Footer({ subscribeEndpoint }) {
  // Always available, on every viewport — this is what lets the invite modal
  // stay desktop-only, and gives anyone who opted out a way back in.
  const [alreadyOn] = useState(nlSubscribed);
  return (
    <footer className="footer">
      <div className="footer-top">
        <div className="footer-brand">
          <div className="footer-mark">P·E</div>
          <div className="footer-tag">Parallel Editions publishes public-domain classics side by side, in either direction, across multiple languages.</div>
          {subscribeEndpoint && (
            alreadyOn ? (
              <p className="footer-subscribed">You're on the list. Thanks.</p>
            ) : (
              <SubscribeForm endpoint={subscribeEndpoint} source="footer" />
            )
          )}
        </div>
        <div className="footer-cols">
          <div className="footer-col">
            <div className="footer-col-h">Browse</div>
            <a href="#catalog">All editions</a>
            <a href="#pitch">The format</a>
          </div>
          <div className="footer-col">
            <div className="footer-col-h">Marketplaces</div>
            <span>amazon.com · .co.uk · .de</span>
            <span>amazon.fr · .es · .it</span>
            <span>amazon.co.jp · .ca · .com.au</span>
          </div>
        </div>
      </div>
      <div className="footer-bot">
        <span>© 2016 - {new Date().getFullYear()} Parallel Editions</span>
        <a className="footer-privacy" href="/privacy.html">Privacy</a>
      </div>
    </footer>
  );
}

Object.assign(window, {
  TypographicCover, PhotoCover, BookCard, LanguageFilter, PublishedFilter,
  PitchStrip, Hero, Catalog, RequestsForm, Masthead, Footer,
  SampleReaderModal, NewsletterInvite, SubscribeForm, ClassroomBand,
});
