/* ------------------------------------------------------------------ Mthimkhulu Welanga Organisation, NPO Navigation, forms (appointment / comment / anonymous) and QR tool. ------------------------------------------------------------------ */ (function () { 'use strict'; var CFG = window.MW_CONFIG || {}; var $ = function (sel, ctx) { return (ctx || document).querySelector(sel); }; var $$ = function (sel, ctx) { return Array.prototype.slice.call((ctx || document).querySelectorAll(sel)); }; var WA_NUMBER = CFG.whatsapp || '27825550282'; var EMAIL = CFG.email || 'mthiweorg@gmail.com'; var PENDING_KEY = 'mw_pending_v1'; /* ================================================================ Header + navigation ================================================================ */ var header = $('.site-header'); var navToggle = $('#nav-toggle'); var nav = $('#site-nav'); function onScroll() { if (header) header.classList.toggle('is-scrolled', window.scrollY > 8); } window.addEventListener('scroll', onScroll, { passive: true }); onScroll(); function closeNav() { if (!nav || !navToggle) return; nav.classList.remove('is-open'); navToggle.setAttribute('aria-expanded', 'false'); } if (navToggle && nav) { navToggle.addEventListener('click', function () { var open = navToggle.getAttribute('aria-expanded') === 'true'; navToggle.setAttribute('aria-expanded', String(!open)); nav.classList.toggle('is-open', !open); }); nav.addEventListener('click', function (e) { if (e.target.closest('a')) closeNav(); }); document.addEventListener('click', function (e) { if (!nav.classList.contains('is-open')) return; if (e.target.closest('#site-nav') || e.target.closest('#nav-toggle')) return; closeNav(); }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeNav(); }); window.addEventListener('resize', function () { if (window.innerWidth > 900) closeNav(); }); } /* highlight the section you are reading */ var navLinks = $$('#site-nav a[href^="#"]'); var sections = navLinks .map(function (a) { return document.getElementById(a.getAttribute('href').slice(1)); }) .filter(Boolean); if ('IntersectionObserver' in window && sections.length) { var spy = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (!entry.isIntersecting) return; navLinks.forEach(function (a) { a.classList.toggle('is-current', a.getAttribute('href') === '#' + entry.target.id); }); }); }, { rootMargin: '-45% 0px -50% 0px', threshold: 0 }); sections.forEach(function (s) { spy.observe(s); }); } /* ================================================================ Tabs ================================================================ */ var tabs = $$('.tab'); var panels = $$('.tab-panel'); var statusBox = $('#form-status'); function selectTab(name, focus) { tabs.forEach(function (tab) { var active = tab.dataset.tab === name; tab.classList.toggle('is-active', active); tab.setAttribute('aria-selected', String(active)); tab.tabIndex = active ? 0 : -1; if (active && focus) tab.focus(); }); panels.forEach(function (panel) { panel.hidden = panel.id !== 'panel-' + name; }); if (statusBox) { statusBox.hidden = true; statusBox.innerHTML = ''; statusBox.classList.remove('is-error'); } } tabs.forEach(function (tab, index) { tab.addEventListener('click', function () { selectTab(tab.dataset.tab); }); tab.addEventListener('keydown', function (e) { var next = null; if (e.key === 'ArrowRight') next = (index + 1) % tabs.length; if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length; if (e.key === 'Home') next = 0; if (e.key === 'End') next = tabs.length - 1; if (next === null) return; e.preventDefault(); selectTab(tabs[next].dataset.tab, true); }); }); /* deep link: #connect?tab=anon or #anonymous */ if (location.hash === '#anonymous') selectTab('anon'); if (location.hash === '#appointment') selectTab('appointment'); if (location.hash === '#comment') selectTab('comment'); if (location.hash === '#connect') selectTab('appointment'); /* ================================================================ Validation helpers ================================================================ */ function setError(field, message) { var box = document.getElementById(field.id + '-error'); if (message) { field.setAttribute('aria-invalid', 'true'); if (box) box.textContent = message; return false; } field.removeAttribute('aria-invalid'); if (box) box.textContent = ''; return true; } function isEmail(value) { return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value); } function digits(value) { return (value.match(/\d/g) || []).length; } function validateRequired(field, message) { return setError(field, field.value.trim() ? '' : message); } function validateEmail(field, required) { var value = field.value.trim(); if (!value && !required) return setError(field, ''); if (!value) return setError(field, 'Please add your email address.'); if (!isEmail(value)) return setError(field, 'That email address does not look right.'); return setError(field, ''); } function validatePhone(field) { var value = field.value.trim(); if (!value) return setError(field, 'Please add a phone number so we can confirm.'); if (digits(value) < 9) return setError(field, 'Please enter a full phone number (at least 9 digits).'); return setError(field, ''); } function focusFirstInvalid(form) { var bad = form.querySelector('[aria-invalid="true"]'); if (bad) bad.focus(); } /* office hours: Mon–Thu 08:30–16:00, Fri 08:30–14:00, weekends closed */ function validateDate(field) { if (!field.value) return setError(field, 'Please choose a day.'); var d = new Date(field.value + 'T00:00:00'); if (isNaN(d.getTime())) return setError(field, 'Please choose a valid day.'); var day = d.getDay(); if (day === 0 || day === 6) return setError(field, 'We are closed on weekends — please choose Monday to Friday.'); setError(field, ''); return true; } function validateTime(field, dateField) { if (!field.value) return setError(field, 'Please choose a time.'); if (!dateField.value) return setError(field, ''); var d = new Date(dateField.value + 'T00:00:00'); if (!isNaN(d.getTime()) && d.getDay() === 5 && field.value > '13:30') { return setError(field, 'On Fridays we close at 2pm — please choose 1:30pm or earlier.'); } return setError(field, ''); } /* ================================================================ Submitting ================================================================ */ function makeRef() { var stamp = Date.now().toString(36).toUpperCase().slice(-5); var rand = Math.random().toString(36).toUpperCase().slice(2, 5); return 'MW-' + stamp + '-' + rand; } function toLines(type, data) { var label = { appointment: 'Appointment request', comment: 'Comment', anonymous: 'Anonymous message' }[type] || 'Message'; var lines = [label, '']; Object.keys(data).forEach(function (key) { if (!data[key]) return; lines.push(key.charAt(0).toUpperCase() + key.slice(1) + ': ' + data[key]); }); return lines.join('\n'); } function savePending(record) { try { var list = JSON.parse(localStorage.getItem(PENDING_KEY) || '[]'); list.unshift(record); localStorage.setItem(PENDING_KEY, JSON.stringify(list.slice(0, 50))); } catch (e) { /* storage may be blocked — ignore */ } } function request(url, body) { return new Promise(function (resolve, reject) { var controller = new AbortController(); var timer = setTimeout(function () { controller.abort(); }, 9000); fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: controller.signal }).then(function (res) { clearTimeout(timer); if (!res.ok) return reject(new Error('HTTP ' + res.status)); return res.json().catch(function () { return {}; }).then(resolve); }).catch(function (err) { clearTimeout(timer); reject(err); }); }); } function showStatus(html, isError) { if (!statusBox) return; statusBox.innerHTML = html; statusBox.classList.toggle('is-error', !!isError); statusBox.hidden = false; statusBox.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } function fallbackActions(type, text, ref) { if (type === 'anonymous') { return '
WhatsApp and email would reveal your number or address, so they are not offered here. Copy the message and send it through a channel you trust.
'; } var wa = 'https://wa.me/' + WA_NUMBER + '?text=' + encodeURIComponent(text.slice(0, 1200)); var mail = 'mailto:' + EMAIL + '?subject=' + encodeURIComponent('Website: ' + type) + '&body=' + encodeURIComponent(text); return 'Thank you, ' + escapeHtml((data.name || '').split(' ')[0]) + '. We have your request and will confirm during office hours.
' : type === 'anonymous' ? 'We received your message. Nothing that identifies you was attached to it.
' : 'Your comment has reached the team.
'; var refLine = 'Reference
' + ref + '
'; var extra = online ? 'Keep the reference if you need to follow up.
' : 'This page is not connected to a server, so your message is saved on this device only. Send it through one of these channels to make sure it reaches us:
' + fallbackActions(type, text, ref); showStatus(intro + refLine + extra, false); bindCopy(text); form.reset(); if (type === 'appointment') setDateMin(); if (button) { button.disabled = false; button.textContent = original; } }; if (!canPost) { savePending(payload); done(false); return; } var base = (CFG.apiBase || '').replace(/\/$/, ''); request(base + '/api/submit', payload) .then(function () { done(true); }) .catch(function () { savePending(payload); done(false); }); }); } function escapeHtml(value) { return String(value).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } var appointmentForm = $('#form-appointment'); var commentForm = $('#form-comment'); var anonForm = $('#form-anon'); if (appointmentForm) handleSubmit(appointmentForm, 'appointment'); if (commentForm) handleSubmit(commentForm, 'comment'); if (anonForm) handleSubmit(anonForm, 'anonymous'); /* earliest bookable day = today */ function setDateMin() { var date = $('#ap-date'); if (!date) return; var now = new Date(); var iso = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 10); date.min = iso; } setDateMin(); /* live-clear errors as people fix them */ $$('input, textarea, select').forEach(function (field) { field.addEventListener('input', function () { if (field.getAttribute('aria-invalid') === 'true' && field.value.trim()) { field.removeAttribute('aria-invalid'); var box = document.getElementById(field.id + '-error'); if (box) box.textContent = ''; } }); }); /* ================================================================ QR code ================================================================ */ var canvas = $('#qr-canvas'); var linkInput = $('#qr-link'); var qrForm = $('#qr-form'); var qrError = $('#qr-error'); var qrHint = $('#share-hint'); var downloadBtn = $('#qr-download'); var copyBtn = $('#qr-copy'); var printBtn = $('#qr-print'); var swatches = $$('.swatch'); var currentLink = ''; var currentInk = '#11211d'; function normaliseLink(value) { var v = value.trim(); if (!v) return ''; if (/^(https?:|mailto:|tel:)/i.test(v)) return v; if (/^[^\s]+\.[^\s]{2,}/.test(v)) return 'https://' + v; return v; } function isLocalLink(url) { try { var u = new URL(url); return /^(localhost|127\.0\.0\.1|\[::1\])$/i.test(u.hostname); } catch (e) { return false; } } function defaultLink() { if (CFG.shareLink) return CFG.shareLink; if (/^https?:$/.test(location.protocol)) { var u = new URL(location.href); u.hash = ''; return u.href; } return ''; } function renderQR(link) { try { QR.toCanvas(canvas, link, { size: 900, margin: 4, dark: currentInk, light: '#ffffff' }); } catch (err) { qrError.textContent = 'That link is too long for a QR code (' + link.length + ' characters). Try an address under 200 characters.'; return false; } currentLink = link; canvas.setAttribute('aria-label', 'QR code that opens ' + link); if (qrError) qrError.textContent = ''; if (qrHint) { qrHint.innerHTML = 'QR points to ' + escapeHtml(link) + '.'; if (isLocalLink(link)) { qrHint.innerHTML += ' This address only works on this computer. Publish the page first, then paste the public link here so phones can scan it.'; } } return true; } function generateFromInput() { var value = normaliseLink(linkInput.value); if (!value) { qrError.textContent = 'Please add the link you want the QR code to open.'; linkInput.focus(); return; } if (value.length > 200) { qrError.textContent = 'That link is too long for a reliable QR code. Use an address under 200 characters.'; return; } if (renderQR(value)) { linkInput.value = value; downloadBtn.disabled = false; } } if (qrForm && canvas && window.QR) { var initial = defaultLink(); if (initial) { linkInput.value = initial; renderQR(initial); } else if (qrHint) { qrHint.innerHTML = 'Open this page through its link (or type the public web address) to make a scannable code.'; downloadBtn.disabled = true; } qrForm.addEventListener('submit', function (e) { e.preventDefault(); generateFromInput(); }); swatches.forEach(function (swatch) { swatch.addEventListener('click', function () { swatches.forEach(function (s) { s.classList.toggle('is-active', s === swatch); s.setAttribute('aria-pressed', String(s === swatch)); }); currentInk = swatch.dataset.ink; if (currentLink) renderQR(currentLink); }); }); downloadBtn.addEventListener('click', function () { if (!currentLink) return; var a = document.createElement('a'); a.download = 'mthimkhulu-welanga-qr.png'; a.href = canvas.toDataURL('image/png'); document.body.appendChild(a); a.click(); document.body.removeChild(a); if (qrHint) qrHint.innerHTML = 'Saved mthimkhulu-welanga-qr.png to your downloads.'; }); copyBtn.addEventListener('click', function () { if (!currentLink) return; copyText(currentLink).then(function () { copyBtn.textContent = 'Copied'; setTimeout(function () { copyBtn.textContent = 'Copy link'; }, 1800); }).catch(function () { if (qrHint) qrHint.innerHTML = 'Copy this link manually: ' + escapeHtml(currentLink) + ''; }); }); printBtn.addEventListener('click', function () { if (!currentLink) return; window.print(); }); } /* ================================================================ Tiny progressive reveal ================================================================ */ if ('IntersectionObserver' in window && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) { var revealTargets = $$('.programme, .join-card, .detail, .qr-card'); revealTargets.forEach(function (el) { el.classList.add('reveal'); }); var revealObserver = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (!entry.isIntersecting) return; entry.target.classList.add('is-in'); revealObserver.unobserve(entry.target); }); }, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 }); revealTargets.forEach(function (el) { revealObserver.observe(el); }); } })();