← 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) => ({ '<': '<', '>': '>', '&': '&', '"': '"' }[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'; }
});
})();