patx/miadruck

/* ==========================================================================
   Mia Druck — shared behaviour. Loaded with `defer` on every page.
   Listings are static HTML now, so there is no client-side rendering here.
   ========================================================================== */
(function () {
  'use strict';

  var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  /* -- Footer year -------------------------------------------------------- */
  var year = document.getElementById('y');
  if (year) year.textContent = new Date().getFullYear();

  /* -- Mobile nav --------------------------------------------------------- */
  (function () {
    var topbar = document.querySelector('.topbar');
    var btn = document.getElementById('navToggle');
    var nav = document.getElementById('siteNav');
    if (!topbar || !btn || !nav) return;

    function close() {
      topbar.classList.remove('nav-open');
      btn.setAttribute('aria-expanded', 'false');
    }

    btn.addEventListener('click', function () {
      var open = topbar.classList.toggle('nav-open');
      btn.setAttribute('aria-expanded', open ? 'true' : 'false');
    });

    nav.addEventListener('click', function (e) {
      if (e.target.closest('a')) close();
    });

    document.addEventListener('click', function (e) {
      if (!topbar.contains(e.target)) close();
    });

    document.addEventListener('keydown', function (e) {
      if (e.key === 'Escape') close();
    });
  })();

  /* -- Nav scroll spy -----------------------------------------------------
     Marks the nav link for whichever section is currently at the top of the
     viewport. Only same-page anchors that actually resolve take part: the area
     pages link out with "/#…", so they keep their static aria-current="page"
     and this no-ops there. */
  (function () {
    var nav = document.getElementById('siteNav');
    var topbar = document.querySelector('.topbar');
    if (!nav) return;

    var targets = [];
    nav.querySelectorAll('a[href^="#"]').forEach(function (link) {
      var id = link.getAttribute('href').slice(1);
      var section = id && document.getElementById(id);
      if (section) targets.push({ link: link, section: section });
    });

    // One target is not a spy, it's just a link (the area pages' Contact).
    if (targets.length < 2) return;

    var current = null;

    function update() {
      // Sections sit behind the sticky bar, so activate a little below it.
      var line = window.scrollY + (topbar ? topbar.offsetHeight : 0) + 24;
      var atBottom = window.innerHeight + window.scrollY >=
                     document.documentElement.scrollHeight - 2;
      var active = null;
      var bestTop = -1;

      targets.forEach(function (t) {
        // Measured live: nav order does not match document order here, and
        // the layout reflows across breakpoints.
        var top = t.section.getBoundingClientRect().top + window.scrollY;
        if ((atBottom || top <= line) && top > bestTop) {
          bestTop = top;
          active = t;
        }
      });

      if (active === current) return;
      if (current) current.link.removeAttribute('aria-current');
      if (active) active.link.setAttribute('aria-current', 'location');
      current = active;
    }

    var ticking = false;
    function onScroll() {
      if (ticking) return;
      ticking = true;
      requestAnimationFrame(function () { update(); ticking = false; });
    }

    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll, { passive: true });
    update();
  })();

  /* -- Toast -------------------------------------------------------------- */
  var toastTimer;
  function showToast(msg) {
    var t = document.getElementById('copyToast');
    if (!t) {
      t = document.createElement('div');
      t.id = 'copyToast';
      t.className = 'copy-toast';
      t.setAttribute('role', 'status');
      t.setAttribute('aria-live', 'polite');
      document.body.appendChild(t);
    }
    t.textContent = msg;
    t.classList.add('show');
    clearTimeout(toastTimer);
    toastTimer = setTimeout(function () { t.classList.remove('show'); }, 1800);
  }

  /* -- Listing deep links ------------------------------------------------- */
  var highlightTimer;

  function highlight(id) {
    var el = document.getElementById(id);
    if (!el) return false;

    document.querySelectorAll('.listing.is-highlighted')
      .forEach(function (n) { n.classList.remove('is-highlighted'); });

    el.scrollIntoView({
      behavior: reduceMotion ? 'auto' : 'smooth',
      block: 'center'
    });

    setTimeout(function () {
      el.classList.add('is-highlighted');
      clearTimeout(highlightTimer);
      highlightTimer = setTimeout(function () {
        el.classList.remove('is-highlighted');
      }, 20000);
    }, 220);

    return true;
  }

  function shareUrl(id) {
    // Listings only live on the homepage, so always build the link against it.
    var u = new URL(location.origin + '/');
    u.searchParams.set('listing', id);
    return u.toString();
  }

  // Delegated — works for any listing added to the HTML later.
  document.addEventListener('click', function (e) {
    var btn = e.target.closest('[data-copy-listing]');
    if (!btn) return;

    var id = btn.getAttribute('data-copy-listing');
    var url = shareUrl(id);

    function done() {
      showToast('Link copied');
      highlight(id);
    }

    if (navigator.clipboard && window.isSecureContext) {
      navigator.clipboard.writeText(url).then(done, function () {
        window.prompt('Copy this link:', url);
      });
    } else {
      var ta = document.createElement('textarea');
      ta.value = url;
      ta.setAttribute('readonly', '');
      ta.style.position = 'fixed';
      ta.style.opacity = '0';
      document.body.appendChild(ta);
      ta.select();
      try { document.execCommand('copy'); done(); }
      catch (err) { window.prompt('Copy this link:', url); }
      ta.remove();
    }
  });

  var deepLink = new URLSearchParams(location.search).get('listing');
  if (deepLink) setTimeout(function () { highlight(deepLink); }, 300);

  /* -- Contact form (Formspree, AJAX) ------------------------------------- */
  (function () {
    var form = document.getElementById('contactForm');
    var success = document.getElementById('contactSuccess');
    if (!form || !success) return;

    form.addEventListener('submit', function (e) {
      e.preventDefault();

      // Honeypot: bots fill hidden fields, humans never see this one.
      if (form.querySelector('[name="_gotcha"]').value) return;

      var btn = form.querySelector('button[type="submit"]');
      var label = btn ? btn.textContent : 'Send';
      if (btn) { btn.disabled = true; btn.textContent = 'Sending…'; }

      fetch(form.action, {
        method: 'POST',
        headers: { Accept: 'application/json' },
        body: new FormData(form)
      }).then(function (res) {
        if (!res.ok) throw new Error('Bad response');
        form.classList.add('hide');
        success.classList.remove('hide');
        success.setAttribute('tabindex', '-1');
        success.focus();
        form.reset();
      }).catch(function () {
        showToast('Could not send — please call');
        if (btn) { btn.disabled = false; btn.textContent = label; }
      });
    });
  })();

  /* -- Scroll reveal ------------------------------------------------------ */
  (function () {
    var targets = document.querySelectorAll('.reveal');
    if (!targets.length) return;

    if (reduceMotion || !('IntersectionObserver' in window)) {
      targets.forEach(function (el) { el.classList.add('is-visible'); });
      return;
    }

    var io = new IntersectionObserver(function (entries) {
      entries.forEach(function (entry) {
        if (!entry.isIntersecting) return;
        entry.target.classList.add('is-visible');
        io.unobserve(entry.target);
      });
    }, { rootMargin: '0px 0px -12% 0px', threshold: 0.05 });

    targets.forEach(function (el) { io.observe(el); });
  })();
})();