← back to Robet Site

public/app.js

120 lines

// ROBET front-end: hydrate config, hamburger, Smart Scheduling embed, contact form.
(async function () {
  const $ = (s) => document.querySelector(s);
  let cfg = {};
  try { cfg = await (await fetch('/api/config')).json(); } catch (_) {}

  // ── brand + hero from config ──
  if (cfg.brand) {
    $('#logo').textContent = cfg.brand;
    $('#footBrand').textContent = cfg.brand;
    document.title = cfg.brand;
  }
  if (cfg.heroHeadline) $('#heroHeadline').textContent = cfg.heroHeadline;
  if (cfg.heroSub) $('#heroSub').textContent = cfg.heroSub;
  const heroUrl = (cfg.heroImage || '/hero.jpg');
  $('#heroImg').style.backgroundImage = `url("${heroUrl}")`;
  $('#year').textContent = new Date().getFullYear();

  // ── contact info ──
  const c = cfg.contact || {};
  const info = $('#contactInfo');
  const row = (label, val) => val ? `<dt>${label}</dt><dd>${val}</dd>` : '';
  info.innerHTML =
    row('Email', c.email ? `<a href="mailto:${c.email}">${c.email}</a>` : '') +
    row('Phone', c.phone ? `<a href="tel:${c.phone}">${c.phone}</a>` : '') +
    row('Address', c.address) +
    row('Hours', c.hours);

  // ── hamburger (with focus trap for a11y) ──
  const panel = $('#navpanel'), burger = $('#hamburger'), navClose = $('#navclose');
  const focusables = () => [...panel.querySelectorAll('a, button')].filter((el) => el.offsetParent !== null);
  const open = (v) => {
    panel.classList.toggle('open', v);
    burger.setAttribute('aria-expanded', String(v));
    panel.setAttribute('aria-hidden', String(!v));
    if (v) { navClose.focus(); }        // move focus into the dialog
    else { burger.focus(); }            // restore focus to the trigger on close
  };
  panel.setAttribute('aria-hidden', 'true');
  burger.addEventListener('click', () => open(true));
  navClose.addEventListener('click', () => open(false));
  panel.querySelectorAll('a').forEach((a) => a.addEventListener('click', () => open(false)));
  document.addEventListener('keydown', (e) => {
    if (!panel.classList.contains('open')) return;
    if (e.key === 'Escape') { open(false); return; }
    if (e.key === 'Tab') {                // cycle focus within the open dialog
      const f = focusables(); if (!f.length) return;
      const first = f[0], last = f[f.length - 1];
      if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
      else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
    }
  });

  // ── Booking: link to Robert's OWN scheduler (Calendly/Cal/Square). Until set → contact form. ──
  const sched = cfg.scheduling || {};
  const bookPrimary = $('#bookPrimary');
  if (bookPrimary) {
    if (sched.bookUrl) {
      bookPrimary.href = sched.bookUrl;
    } else {
      bookPrimary.href = '#contact';
      bookPrimary.textContent = 'Get in touch to book →';
      bookPrimary.removeAttribute('target');
      bookPrimary.removeAttribute('rel');
    }
  }

  // ── gallery: render real photos from /api/photos, else tasteful placeholders ──
  (async () => {
    const grid = $('#galleryGrid'); if (!grid) return;
    let photos = [];
    try { photos = await (await fetch('/api/photos')).json(); } catch (_) {}
    if (photos.length) {
      grid.innerHTML = photos.map((src) =>
        `<a class="shot" href="${src}" target="_blank" rel="noopener noreferrer">
           <img src="${src}" alt="A cut by Robert" loading="lazy" width="600" height="600" />
         </a>`).join('');
    } else {
      // pending state — looks intentional, not broken, until real photos are dropped in public/photos
      grid.innerHTML = Array.from({ length: 6 }, (_, i) =>
        `<div class="shot shot--pending"><span class="mono">PHOTO ${i + 1}</span></div>`).join('') +
        `<p class="mono gallery-note">Robert's cuts go here — drop photos in <code>public/photos/</code>.</p>`;
    }
  })();

  // ── reviews from Booksy (public/reviews.json) ──
  (async () => {
    const grid = $('#reviewsGrid'); if (!grid) return;
    let data = {};
    try { data = await (await fetch('/reviews.json')).json(); } catch (_) { return; }
    const link = $('#reviewsLink'); if (link && data.sourceUrl) link.href = data.sourceUrl;
    const stars = (n) => '★'.repeat(n) + '☆'.repeat(5 - n);
    const esc = (s) => String(s || '').replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
    grid.innerHTML = (data.items || []).map((r) =>
      `<div class="review">
         <div class="stars">${stars(r.rank)}</div>
         ${r.text ? `<p class="quote">“${esc(r.text)}”</p>` : ''}
         <div class="who mono">${esc(r.author)}</div>
         <div class="svc mono">${esc(r.service)}</div>
         <div class="when mono">${esc(r.date)}</div>
       </div>`).join('');
  })();

  // ── contact form ──
  $('#contactForm').addEventListener('submit', async (e) => {
    e.preventDefault();
    const msg = $('#formMsg');
    const body = { name: $('#cName').value, email: $('#cEmail').value, message: $('#cMsg').value, website: $('#hp_website').value };
    msg.textContent = 'Sending…';
    try {
      const r = await fetch('/api/contact', {
        method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
      });
      const j = await r.json();
      msg.textContent = j.ok ? "✓ Got it. I'll be in touch." : '✗ ' + (j.error || 'Failed');
      if (j.ok) e.target.reset();
    } catch (_) { msg.textContent = '✗ Network error'; }
  });
})();