(function () { 'use strict'; const PAGE_SCRIPTS = {"index.html": ["\n function esc(str) {\n return String(str).replace(/&/g, '&').replace(//g, '>');\n }\n\n async function openApiKeyModal() {\n document.getElementById('apiKeyInput').value = '';\n document.getElementById('apiKeyError').textContent = '';\n const statusEl = document.getElementById('apiKeyStatus');\n statusEl.textContent = 'Checking\u2026';\n try {\n const [keyData, costData] = await Promise.all([\n fetch('/api/settings.php?key=clickatell_api_key').then(r => r.json()),\n fetch('/api/settings.php?key=clickatell_cost_per_sms').then(r => r.json()),\n ]);\n statusEl.innerHTML = keyData.set\n ? `API key set: ${esc(keyData.hint)} \u2014 enter a new key to replace it.`\n : 'No API key configured. Enter one below to enable SMS sending.';\n document.getElementById('costPerSmsInput').value = costData.value ?? '0.04849';\n } catch {\n statusEl.textContent = 'Could not load current settings.';\n }\n }\n\n async function saveApiKey() {\n const key = document.getElementById('apiKeyInput').value.trim();\n const cost = document.getElementById('costPerSmsInput').value.trim();\n const errEl = document.getElementById('apiKeyError');\n const btn = document.getElementById('apiKeySaveBtn');\n errEl.textContent = '';\n if (!key && !cost) { errEl.textContent = 'Please enter at least one value.'; return; }\n const costNum = parseFloat(cost);\n if (cost && (isNaN(costNum) || costNum < 0)) { errEl.textContent = 'Cost must be a positive number.'; return; }\n btn.disabled = true;\n try {\n const saves = [];\n if (key) saves.push(fetch('/api/settings.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'clickatell_api_key', value: key }) }));\n if (cost) saves.push(fetch('/api/settings.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'clickatell_cost_per_sms', value: cost }) }));\n const results = await Promise.all(saves);\n for (const res of results) { if (!res.ok) throw new Error((await res.json()).error ?? 'Save failed'); }\n bootstrap.Modal.getInstance(document.getElementById('apiKeyModal')).hide();\n loadStats();\n } catch (e) {\n errEl.textContent = e.message;\n } finally {\n btn.disabled = false;\n }\n }\n\n let spendView = 'balance';\n let spendData = { balance: null, currency: null, estimatedCost: null };\n\n function formatCurrency(amount, currency) {\n if (amount == null || Number.isNaN(Number(amount))) return '\u2014';\n const sym = currency === 'GBP' ? '\u00a3' : currency === 'USD' ? '$' : currency === 'EUR' ? '\u20ac' : (currency ?? '');\n return sym + Number(amount).toFixed(2);\n }\n\n function renderSpendCard() {\n const labelEl = document.getElementById('stat-spend-label');\n const valueEl = document.getElementById('stat-spend');\n if (!labelEl || !valueEl) return;\n\n if (spendView === 'cost') {\n labelEl.textContent = 'EST. COST';\n valueEl.textContent = formatCurrency(spendData.estimatedCost, spendData.currency);\n return;\n }\n\n labelEl.textContent = 'Balance';\n valueEl.textContent = formatCurrency(spendData.balance, spendData.currency);\n }\n\n function toggleSpendCard() {\n spendView = spendView === 'balance' ? 'cost' : 'balance';\n renderSpendCard();\n }\n\n async function loadStats() {\n try {\n const [att, log, bal, cost, admin] = await Promise.all([\n fetch('/api/attendees.php').then(r => r.json()),\n fetch('/api/delivery-log.php').then(r => r.json()),\n fetch('/api/balance.php').then(r => r.json()),\n fetch('/api/settings.php?key=clickatell_cost_per_sms').then(r => r.json()),\n fetch('/api/admin.php').then(r => r.json()),\n ]);\n document.getElementById('stat-attendees').textContent = att.total ?? '\u2014';\n document.getElementById('stat-sent').textContent = log.stats?.total_sent ?? '\u2014';\n const rate = log.stats?.success_rate;\n document.getElementById('stat-rate').textContent = rate != null ? rate + '%' : '\u2014';\n spendData.balance = bal.balance != null ? Number(bal.balance) : null;\n spendData.currency = bal.currency ?? 'GBP';\n\n const realSent = Number(admin.delivery_log_real_sent);\n const costPerSms = Number(cost.value);\n spendData.estimatedCost = Number.isFinite(realSent) && Number.isFinite(costPerSms)\n ? realSent * costPerSms\n : null;\n\n renderSpendCard();\n } catch { /* DB unavailable \u2014 stats stay as dashes */ }\n }\n document.getElementById('spendCard')?.addEventListener('click', toggleSpendCard);\n document.getElementById('spendCard')?.addEventListener('keydown', e => {\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n toggleSpendCard();\n }\n });\n loadStats();\n setInterval(() => loadStats(), 10_000);\n ", "\n async function checkAuth() {\n try {\n const r = await fetch('/api/admin.php', { redirect: 'manual' });\n if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }\n const d = await r.json();\n document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';\n document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));\n const adminSection = document.getElementById('adminSection');\n if (adminSection) adminSection.classList.toggle('d-none', !d.is_admin);\n const name = d.name || d.username;\n if (name) {\n document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });\n }\n } catch { }\n }\n checkAuth();\n document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });\n "], "attendees.html": ["\n Dropzone.autoDiscover = false;\n\n let pendingFile = null;\n let currentDuplicates = [];\n\n const dz = new Dropzone(\"#csvDropzone\", {\n url: \"/api/attendees.php\",\n acceptedFiles: \".csv\",\n maxFiles: 1,\n maxFilesize: 5,\n autoProcessQueue: false,\n dictDefaultMessage: \"\",\n params: () => ({ mode: document.querySelector('input[name=\"importMode\"]:checked').value }),\n });\n\n dz.on(\"addedfile\", (file) => {\n document.getElementById('confirmUploadFilename').textContent = file.name;\n document.getElementById('confirmUploadBar').classList.remove('d-none');\n document.getElementById('importResult').innerHTML = '';\n });\n\n dz.on(\"removedfile\", () => {\n document.getElementById('confirmUploadBar').classList.add('d-none');\n document.getElementById('confirmUploadFilename').textContent = '';\n });\n\n function confirmUpload() {\n dz.processQueue();\n }\n\n function cancelUpload() {\n dz.removeAllFiles();\n }\n\n dz.on(\"success\", (file, response) => {\n const r = typeof response === 'string' ? JSON.parse(response) : response;\n if (r.status === 'needs_resolution') {\n pendingFile = file;\n currentDuplicates = r.duplicates;\n showDuplicateModal(r.duplicates);\n return;\n }\n dz.removeFile(file);\n const resultEl = document.getElementById('importResult');\n const parts = [];\n if (r.imported) parts.push(`${r.imported} added`);\n if (r.updated) parts.push(`${r.updated} updated`);\n if (r.skipped) {\n const reasons = [];\n if (r.skip_reasons?.not_attending) reasons.push(`${r.skip_reasons.not_attending} not attending`);\n if (r.skip_reasons?.no_mobile) reasons.push(`${r.skip_reasons.no_mobile} invalid/missing mobile`);\n const detail = reasons.length ? ` (${reasons.join(', ')})` : '';\n parts.push(`${r.skipped} skipped${detail}`);\n }\n resultEl.innerHTML = parts.length ? 'Import complete: ' + parts.join(', ') + '.' : 'Import complete.';\n if (r.rejected && r.rejected.length > 0) {\n showRejectedRecords(r.rejected);\n } else {\n rejectedRows = [];\n renderRejectedTable();\n }\n loadAttendees();\n });\n\n dz.on(\"error\", (file, msg) => {\n const errText = typeof msg === 'object' ? (msg.error ?? JSON.stringify(msg)) : msg;\n document.getElementById('importResult').innerHTML = `Import failed: ${esc(errText)}`;\n dz.removeFile(file);\n });\n\n let table = null;\n\n async function loadAttendees() {\n const tbody = document.getElementById('attendeeBody');\n const countEl = document.getElementById('attendeeCount');\n\n // Destroy existing DataTable instance before touching the DOM\n if (table) { table.destroy(); table = null; }\n\n tbody.innerHTML = 'Loading\u2026';\n try {\n const data = await fetch('/api/attendees.php').then(r => r.json());\n const list = data.attendees ?? [];\n window._attendeeById = {};\n list.forEach(a => { window._attendeeById[a.id] = a; });\n countEl.textContent = list.length + ' total';\n if (list.length === 0) {\n tbody.innerHTML = 'No attendees yet. Upload a CSV to get started.';\n return;\n }\n tbody.innerHTML = list.map(a => `\n \n ${esc(a.first_name + ' ' + a.last_name)}\n ${esc(a.email ?? '')}\n ${esc(a.mobile_number)}\n ${esc(a.workshops ?? '\u2014')}\n ${formatWorkshopTimes(a.workshop_times)}\n \n \n \n \n `).join('');\n table = new DataTable('#attendeesTable', { columnDefs: [{ orderable: false, targets: -1 }] });\n } catch {\n tbody.innerHTML = 'Could not load attendees.';\n }\n }\n\n function esc(str) {\n return String(str).replace(/&/g, '&').replace(//g, '>');\n }\n\n function formatWorkshopTimes(timesStr) {\n if (!timesStr) return '\u2014';\n return esc(timesStr.split(',').map(t => {\n const d = new Date(t.trim().replace(' ', 'T'));\n return isNaN(d.getTime()) ? t.trim() : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });\n }).join(', '));\n }\n\n async function purgeAttendees() {\n if (!confirm('This will permanently delete all attendees and their registrations. Are you sure?')) return;\n const btn = document.getElementById('purgeBtn');\n btn.disabled = true;\n try {\n const res = await fetch('/api/attendees.php', { method: 'DELETE' });\n if (!res.ok) throw new Error(await res.text());\n loadAttendees();\n } catch (e) {\n alert('Purge failed: ' + e.message);\n } finally {\n btn.disabled = false;\n }\n }\n\n async function openApiKeyModal() {\n document.getElementById('apiKeyInput').value = '';\n document.getElementById('apiKeyError').textContent = '';\n const statusEl = document.getElementById('apiKeyStatus');\n statusEl.textContent = 'Checking\u2026';\n try {\n const [keyData, costData] = await Promise.all([\n fetch('/api/settings.php?key=clickatell_api_key').then(r => r.json()),\n fetch('/api/settings.php?key=clickatell_cost_per_sms').then(r => r.json()),\n ]);\n statusEl.innerHTML = keyData.set\n ? `API key set: ${esc(keyData.hint)} \u2014 enter a new key to replace it.`\n : 'No API key configured. Enter one below to enable SMS sending.';\n document.getElementById('costPerSmsInput').value = costData.value ?? '0.04849';\n } catch {\n statusEl.textContent = 'Could not load current settings.';\n }\n }\n\n async function saveApiKey() {\n const key = document.getElementById('apiKeyInput').value.trim();\n const cost = document.getElementById('costPerSmsInput').value.trim();\n const errEl = document.getElementById('apiKeyError');\n const btn = document.getElementById('apiKeySaveBtn');\n errEl.textContent = '';\n if (!key && !cost) { errEl.textContent = 'Please enter at least one value.'; return; }\n const costNum = parseFloat(cost);\n if (cost && (isNaN(costNum) || costNum < 0)) { errEl.textContent = 'Cost must be a positive number.'; return; }\n btn.disabled = true;\n try {\n const saves = [];\n if (key) saves.push(fetch('/api/settings.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'clickatell_api_key', value: key }) }));\n if (cost) saves.push(fetch('/api/settings.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'clickatell_cost_per_sms', value: cost }) }));\n const results = await Promise.all(saves);\n for (const res of results) { if (!res.ok) throw new Error((await res.json()).error ?? 'Save failed'); }\n bootstrap.Modal.getInstance(document.getElementById('apiKeyModal')).hide();\n } catch (e) {\n errEl.textContent = e.message;\n } finally {\n btn.disabled = false;\n }\n }\n\n loadAttendees();\n\n // \u2500\u2500\u2500 Rejected records \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n let rejectedRows = [];\n let rejectedFilter = 'no_mobile';\n\n function showRejectedRecords(rejected) {\n rejectedRows = rejected.map(r => ({ ...r, dismissed: false }));\n rejectedFilter = 'no_mobile';\n document.querySelectorAll('#rejectedFilter button').forEach(b => b.classList.remove('active'));\n document.querySelector('#rejectedFilter button[data-filter=\"no_mobile\"]').classList.add('active');\n renderRejectedTable();\n }\n\n function renderRejectedTable() {\n const section = document.getElementById('rejectedSection');\n const tbody = document.getElementById('rejectedBody');\n const countEl = document.getElementById('rejectedCount');\n\n const active = rejectedRows.filter(r => !r.dismissed);\n if (active.length === 0) { section.classList.add('d-none'); return; }\n\n const visible = rejectedFilter === 'all'\n ? active\n : rejectedFilter === 'not_attending'\n ? active.filter(r => r.reason === 'not_attending')\n : active.filter(r => r.reason !== 'not_attending');\n section.classList.remove('d-none');\n countEl.textContent = active.length;\n\n if (visible.length === 0) {\n tbody.innerHTML = 'No records match this filter.';\n return;\n }\n\n tbody.innerHTML = visible.map(r => {\n const idx = rejectedRows.indexOf(r);\n const reasonBadges = {\n not_attending: 'Not Attending',\n no_mobile: 'No Mobile',\n foreign_country_code: 'Foreign Number',\n invalid_number: 'Invalid Number',\n };\n const reasonBadge = reasonBadges[r.reason] ?? reasonBadges.invalid_number;\n const addBtn = r.reason !== 'not_attending'\n ? ``\n : '';\n return `\n \n ${esc(r.first_name + ' ' + r.last_name)}\n ${esc(r.email ?? '')}\n ${esc(r.raw_mobile || '\u2014')}\n ${esc(r.status || '\u2014')}\n ${reasonBadge}\n \n ${addBtn}\n \n \n `;\n }).join('');\n }\n\n function dismissRejected(idx) {\n rejectedRows[idx].dismissed = true;\n renderRejectedTable();\n }\n\n function dismissAllRejected() {\n rejectedRows.forEach(r => { r.dismissed = true; });\n renderRejectedTable();\n }\n\n function addRejectedManually(idx) {\n const r = rejectedRows[idx];\n openEditModal({\n id: 0,\n first_name: r.first_name,\n last_name: r.last_name,\n email: r.email ?? '',\n mobile_number: r.raw_mobile ?? '',\n workshop_session_id: null,\n _rejectedIdx: idx,\n });\n }\n\n document.querySelectorAll('#rejectedFilter button').forEach(btn => {\n btn.addEventListener('click', () => {\n document.querySelectorAll('#rejectedFilter button').forEach(b => b.classList.remove('active'));\n btn.classList.add('active');\n rejectedFilter = btn.dataset.filter;\n renderRejectedTable();\n });\n });\n\n let editingAttendee = null;\n\n function openEditModalById(id) {\n const a = window._attendeeById?.[id];\n if (!a) return;\n openEditModal(a);\n }\n\n function openEditModal(a) {\n editingAttendee = a;\n document.getElementById('editFirstName').value = a.first_name ?? '';\n document.getElementById('editLastName').value = a.last_name ?? '';\n document.getElementById('editEmail').value = a.email ?? '';\n document.getElementById('editMobile').value = a.mobile_number ?? '';\n document.getElementById('editError').textContent = '';\n\n const sel = document.getElementById('editWorkshop');\n sel.innerHTML = '';\n fetch('/api/workshops.php').then(r => r.json()).then(data => {\n const workshops = data.workshops ?? [];\n // Group sessions by workshop name (case-insensitive) so capitalisation\n // variants (e.g. \"Hot Sauce tasting\" vs \"Hot Sauce Tasting\") are merged.\n const groups = {};\n workshops.forEach(ws => {\n const key = ws.workshop_name.toLowerCase();\n if (!groups[key]) groups[key] = { label: ws.workshop_name, sessions: [] };\n groups[key].sessions.push(ws);\n });\n Object.values(groups).forEach(({ label, sessions }) => {\n const fmtTime = ws => ws.workshop_time\n ? new Date(ws.workshop_time.replace(' ', 'T')).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })\n : null;\n if (sessions.length === 1) {\n const ws = sessions[0];\n const opt = document.createElement('option');\n opt.value = ws.id;\n const time = fmtTime(ws);\n opt.textContent = label + (time ? ' (' + time + ')' : '');\n if (String(ws.id) === String(a.workshop_session_id)) opt.selected = true;\n sel.appendChild(opt);\n } else {\n const grp = document.createElement('optgroup');\n grp.label = label;\n sessions.forEach(ws => {\n const opt = document.createElement('option');\n opt.value = ws.id;\n opt.textContent = fmtTime(ws) ?? '(no time)';\n if (String(ws.id) === String(a.workshop_session_id)) opt.selected = true;\n grp.appendChild(opt);\n });\n sel.appendChild(grp);\n }\n });\n });\n\n new bootstrap.Modal(document.getElementById('editModal')).show();\n }\n\n async function saveEdit() {\n const errEl = document.getElementById('editError');\n const btn = document.getElementById('editSaveBtn');\n errEl.textContent = '';\n btn.disabled = true;\n try {\n const wsVal = document.getElementById('editWorkshop').value;\n const res = await fetch('/api/attendees.php', {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n id: editingAttendee.id,\n first_name: document.getElementById('editFirstName').value.trim(),\n last_name: document.getElementById('editLastName').value.trim(),\n email: document.getElementById('editEmail').value.trim(),\n mobile_number: document.getElementById('editMobile').value.trim(),\n workshop_session_id: wsVal ? parseInt(wsVal, 10) : 0,\n }),\n });\n const data = await res.json();\n if (!res.ok) throw new Error(data.error ?? 'Save failed');\n bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();\n // If this was a manual-add from the rejected table, dismiss that row\n if (editingAttendee._rejectedIdx !== undefined) {\n rejectedRows[editingAttendee._rejectedIdx].dismissed = true;\n renderRejectedTable();\n }\n loadAttendees();\n } catch (e) {\n errEl.textContent = e.message;\n } finally {\n btn.disabled = false;\n }\n }\n\n function showDuplicateModal(duplicates) {\n const container = document.getElementById('dupGroups');\n container.innerHTML = duplicates.map((dup, gi) => {\n const rowHtml = dup.rows.map((row, ri) => `\n
\n \n \n
`).join('');\n return `\n
\n
\n ${esc(dup.mobile)}\n
\n ${rowHtml}\n
`;\n }).join('');\n new bootstrap.Modal(document.getElementById('dupModal')).show();\n }\n\n function cancelDuplicates() {\n bootstrap.Modal.getInstance(document.getElementById('dupModal')).hide();\n if (pendingFile) { dz.removeFile(pendingFile); pendingFile = null; }\n currentDuplicates = [];\n }\n\n async function confirmDuplicates() {\n const resolutions = {};\n currentDuplicates.forEach((dup, gi) => {\n const sel = document.querySelector(`input[name=\"dup_${gi}\"]:checked`);\n if (sel) resolutions[dup.mobile] = parseInt(sel.value, 10);\n });\n const btn = document.getElementById('dupConfirmBtn');\n btn.disabled = true;\n btn.innerHTML = ' Importing\u2026';\n try {\n const fd = new FormData();\n fd.append('file', pendingFile, pendingFile.name);\n fd.append('mode', document.querySelector('input[name=\"importMode\"]:checked').value);\n fd.append('resolutions', JSON.stringify(resolutions));\n const res = await fetch('/api/attendees.php', { method: 'POST', body: fd });\n const data = await res.json();\n if (!res.ok) throw new Error(data.error ?? 'Import failed');\n bootstrap.Modal.getInstance(document.getElementById('dupModal')).hide();\n dz.removeFile(pendingFile);\n pendingFile = null;\n currentDuplicates = [];\n const resultEl = document.getElementById('importResult');\n const parts = [];\n if (data.imported) parts.push(`${data.imported} added`);\n if (data.updated) parts.push(`${data.updated} updated`);\n if (data.skipped) {\n const reasons = [];\n if (data.skip_reasons?.not_attending) reasons.push(`${data.skip_reasons.not_attending} not attending`);\n if (data.skip_reasons?.no_mobile) reasons.push(`${data.skip_reasons.no_mobile} invalid/missing mobile`);\n const detail = reasons.length ? ` (${reasons.join(', ')})` : '';\n parts.push(`${data.skipped} skipped${detail}`);\n }\n resultEl.innerHTML = parts.length ? 'Import complete: ' + parts.join(', ') + '.' : 'Import complete.';\n if (data.rejected && data.rejected.length > 0) {\n showRejectedRecords(data.rejected);\n } else {\n rejectedRows = [];\n renderRejectedTable();\n }\n loadAttendees();\n } catch (e) {\n document.getElementById('importResult').innerHTML = `Import failed: ${esc(e.message)}`;\n } finally {\n btn.disabled = false;\n btn.innerHTML = ' Confirm Import';\n }\n }\n\n ", "\n async function checkAuth() {\n try {\n const r = await fetch('/api/admin.php', { redirect: 'manual' });\n if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }\n const d = await r.json();\n document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';\n document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));\n const name = d.name || d.username;\n if (name) {\n document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });\n }\n } catch { }\n }\n checkAuth();\n document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });\n "], "templates.html": ["\n const MAX_CHARS = 160;\n\n // GSM-7 basic + extended character set\n const GSM7 = new Set('@\u00a3$\u00a5\u00e8\u00e9\u00f9\u00ec\u00f2\u00c7\\n\u00d8\u00f8\\r\u00c5\u00e5\u0394_\u03a6\u0393\u039b\u03a9\u03a0\u03a8\u03a3\u0398\u039e\u00c6\u00e6\u00df\u00c9 !\"#\u00a4%&\\'()*+,-./0123456789:;<=>?\u00a1ABCDEFGHIJKLMNOPQRSTUVWXYZ\u00c4\u00d6\u00d1\u00dc\u00a7\u00bfabcdefghijklmnopqrstuvwxyz\u00e4\u00f6\u00f1\u00fc\u00e0\u20ac[]{}\\\\^|~');\n const GSM7_EXTENDED = new Set('\u20ac[]{}\\\\^|~');\n\n function smsEncoding(text) {\n const isUnicode = [...text].some(c => !GSM7.has(c));\n const limit = isUnicode ? 70 : 160;\n const length = isUnicode ? text.length : [...text].reduce((n, c) => n + (GSM7_EXTENDED.has(c) ? 2 : 1), 0);\n return { length, limit, isUnicode };\n }\n\n function updateCharCount(text) {\n const { length, limit, isUnicode } = smsEncoding(text);\n const remaining = limit - length;\n const el = document.getElementById('charCount');\n el.innerHTML = `${length}/${limit} characters${isUnicode ? '   Unicode encoding \u2014 limit reduced to 70' : ''}`;\n el.className = `d-block mb-3 ${remaining <= 10 ? (remaining <= 0 ? 'text-danger' : 'text-warning') : 'text-muted'}`;\n }\n\n function updatePreview() {\n const template = document.getElementById('template').value;\n const previewEl = document.getElementById('preview');\n const previewSection = previewEl.closest('section');\n if (template.trim() === '') {\n previewSection.style.visibility = 'hidden';\n previewEl.textContent = '';\n } else {\n previewSection.style.visibility = 'visible';\n previewEl.textContent = template\n .replaceAll('{name}', 'Jordan')\n .replaceAll('{workshop}', 'Axe Throwing')\n .replaceAll('{workshop_time}', '13:30');\n }\n updateCharCount(template);\n }\n\n async function saveTemplate() {\n const name = document.getElementById('templateName').value.trim();\n const body = document.getElementById('template').value.trim();\n const editId = document.getElementById('editId').value;\n if (!name || !body) { alert('Please enter a template name and message.'); return; }\n\n const isEdit = editId !== '';\n const url = isEdit ? `/api/templates.php?id=${editId}` : '/api/templates.php';\n const method = isEdit ? 'PUT' : 'POST';\n\n const res = await fetch(url, {\n method,\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ name, body }),\n });\n if (!res.ok) {\n const err = await res.json();\n alert('Error: ' + (err.error ?? 'Unknown error'));\n return;\n }\n cancelEdit();\n loadTemplates();\n }\n\n function editTemplate(id, name, body) {\n document.getElementById('editId').value = id;\n document.getElementById('templateName').value = name;\n document.getElementById('template').value = body;\n document.getElementById('formTitle').textContent = 'Edit Template';\n document.getElementById('saveBtnLabel').textContent = 'Update Template';\n document.getElementById('cancelEditBtn').classList.remove('d-none');\n document.getElementById('template').focus();\n updatePreview();\n window.scrollTo({ top: 0, behavior: 'smooth' });\n }\n\n function editTemplateById(id) {\n const t = window._templateById?.[id];\n if (!t) return;\n editTemplate(t.id, t.name, t.body);\n }\n\n function cancelEdit() {\n document.getElementById('editId').value = '';\n document.getElementById('templateName').value = '';\n document.getElementById('template').value = '';\n document.getElementById('formTitle').textContent = 'New Template';\n document.getElementById('saveBtnLabel').textContent = 'Save Template';\n document.getElementById('cancelEditBtn').classList.add('d-none');\n updatePreview();\n }\n\n async function deleteTemplate(id) {\n if (!confirm('Delete this template?')) return;\n const res = await fetch(`/api/templates.php?id=${id}`, { method: 'DELETE' });\n if (!res.ok) { alert('Could not delete template.'); return; }\n loadTemplates();\n }\n\n function esc(str) {\n return String(str).replace(/&/g, '&').replace(//g, '>').replace(/\"/g, '"');\n }\n\n async function loadTemplates() {\n const list = document.getElementById('templateList');\n const countEl = document.getElementById('templateCount');\n list.innerHTML = '

Loading\u2026

';\n try {\n const data = await fetch('/api/templates.php').then(r => r.json());\n const templates = data.templates ?? [];\n window._templateById = {};\n templates.forEach(t => { window._templateById[t.id] = t; });\n countEl.textContent = templates.length + ' template' + (templates.length !== 1 ? 's' : '');\n if (templates.length === 0) {\n list.innerHTML = '

No templates saved yet. Create one above.

';\n return;\n }\n list.innerHTML = templates.map(t => {\n const chars = t.body.length;\n const date = new Date(t.updated_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });\n return `\n
\n
\n
\n
${esc(t.name)}
\n
Last edited ${date}
\n
\n
\n \n \n
\n
\n
\n
${esc(t.body.replaceAll('{name}', 'Jordan').replaceAll('{workshop}', 'Axe Throwing').replaceAll('{workshop_time}', '13:30'))}
\n
\n
\n ${chars}/160 chars\n ${parseInt(t.scheduled_count) > 0 ? `Scheduled` : ''}\n
\n
\n `;\n }).join('');\n } catch {\n list.innerHTML = '

Could not load templates.

';\n }\n }\n\n document.getElementById('template').addEventListener('input', updatePreview);\n updatePreview();\n loadTemplates();\n ", "\n async function checkAuth() {\n try {\n const r = await fetch('/api/admin.php', { redirect: 'manual' });\n if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }\n const d = await r.json();\n document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';\n document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));\n const name = d.name || d.username;\n if (name) {\n document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });\n }\n } catch { }\n }\n checkAuth();\n document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });\n "], "schedule.html": ["\n // \u2500\u2500 Mode toggle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n let currentMode = 'specific';\n\n function setMode(mode) {\n currentMode = mode;\n document.getElementById('relativeFields').style.display = mode === 'relative' ? '' : 'none';\n document.getElementById('specificFields').style.display = mode === 'specific' ? '' : 'none';\n document.getElementById('modeRelative').classList.toggle('active-mode', mode === 'relative');\n document.getElementById('modeSpecific').classList.toggle('active-mode', mode === 'specific');\n }\n\n // \u2500\u2500 Load templates into select \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n async function loadTemplateOptions() {\n const sel = document.getElementById('templateSelect');\n try {\n const data = await fetch('/api/templates.php').then(r => r.json());\n const templates = data.templates ?? [];\n if (templates.length === 0) {\n sel.innerHTML = '';\n return;\n }\n sel.innerHTML = templates.map(t =>\n ``\n ).join('');\n } catch {\n sel.innerHTML = '';\n }\n }\n\n // \u2500\u2500 Add a rule \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n async function addSchedule() {\n const templateId = document.getElementById('templateSelect').value;\n if (!templateId) { alert('Please select a template.'); return; }\n\n const payload = { template_id: parseInt(templateId), mode: currentMode };\n\n if (currentMode === 'relative') {\n payload.offset_value = parseInt(document.getElementById('offsetValue').value) || 1;\n payload.offset_unit = document.getElementById('offsetUnit').value;\n } else {\n const dt = document.getElementById('specificDatetime').value;\n if (!dt) { alert('Please pick a date and time.'); return; }\n payload.specific_datetime = dt;\n }\n\n const res = await fetch('/api/schedule.php', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n });\n if (!res.ok) {\n const err = await res.json();\n alert('Error: ' + (err.error ?? 'Unknown error'));\n return;\n }\n loadSchedule();\n }\n\n // \u2500\u2500 Edit modal \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n let editMode = 'specific';\n\n function setEditMode(mode) {\n editMode = mode;\n document.getElementById('editRelativeFields').style.display = mode === 'relative' ? '' : 'none';\n document.getElementById('editSpecificFields').style.display = mode === 'specific' ? '' : 'none';\n document.getElementById('editModeRelative').classList.toggle('active-mode', mode === 'relative');\n document.getElementById('editModeSpecific').classList.toggle('active-mode', mode === 'specific');\n }\n\n async function openEditModal(id) {\n const rule = window._scheduleRules?.[id];\n if (!rule) return;\n document.getElementById('editId').value = rule.id;\n setEditMode(rule.mode);\n\n if (rule.mode === 'specific') {\n // Convert stored datetime to datetime-local format (YYYY-MM-DDTHH:MM)\n const dt = new Date(rule.specific_datetime.replace(' ', 'T'));\n const pad = n => String(n).padStart(2, '0');\n document.getElementById('editSpecificDatetime').value =\n `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;\n } else {\n document.getElementById('editOffsetValue').value = rule.offset_value;\n document.getElementById('editOffsetUnit').value = rule.offset_unit;\n }\n\n // Populate template select, mirroring the main select\n const mainSel = document.getElementById('templateSelect');\n const editSel = document.getElementById('editTemplateSelect');\n editSel.innerHTML = mainSel.innerHTML;\n editSel.value = rule.template_id;\n\n bootstrap.Modal.getOrCreateInstance(document.getElementById('editModal')).show();\n }\n\n async function saveEdit() {\n const id = parseInt(document.getElementById('editId').value);\n const templateId = parseInt(document.getElementById('editTemplateSelect').value);\n if (!templateId) { alert('Please select a template.'); return; }\n\n const payload = { template_id: templateId, mode: editMode };\n\n if (editMode === 'relative') {\n payload.offset_value = parseInt(document.getElementById('editOffsetValue').value) || 1;\n payload.offset_unit = document.getElementById('editOffsetUnit').value;\n } else {\n const dt = document.getElementById('editSpecificDatetime').value;\n if (!dt) { alert('Please pick a date and time.'); return; }\n payload.specific_datetime = dt;\n }\n\n const res = await fetch(`/api/schedule.php?id=${id}`, {\n method: 'PUT',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n });\n if (!res.ok) {\n const err = await res.json();\n alert('Error: ' + (err.error ?? 'Unknown error'));\n return;\n }\n bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();\n loadSchedule();\n }\n\n // \u2500\u2500 Remove a rule \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n async function removeSchedule(id) {\n if (!confirm('Remove this scheduled rule?')) return;\n const res = await fetch(`/api/schedule.php?id=${id}`, { method: 'DELETE' });\n if (!res.ok) { alert('Could not remove rule.'); return; }\n loadSchedule();\n }\n\n // \u2500\u2500 Rendering \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function relativeTime(date) {\n const diffMs = date - Date.now();\n const diffMin = Math.round(diffMs / 60000);\n if (diffMin < 1) return 'now';\n if (diffMin < 60) return `in ${diffMin} minute${diffMin !== 1 ? 's' : ''}`;\n const diffHr = Math.round(diffMin / 60);\n if (diffHr < 24) return `in ${diffHr} hour${diffHr !== 1 ? 's' : ''}`;\n const diffDay = Math.round(diffHr / 24);\n return `in ${diffDay} day${diffDay !== 1 ? 's' : ''}`;\n }\n\n function formatDateTime(date) {\n return date.toLocaleString('en-GB', {\n day: 'numeric', month: 'short', year: 'numeric',\n hour: '2-digit', minute: '2-digit',\n });\n }\n\n function ruleLabel(rule) {\n if (rule.mode === 'specific') {\n return 'Specific time: ' + formatDateTime(new Date(rule.specific_datetime));\n }\n const unit = { minutes: 'minute', hours: 'hour', days: 'day' }[rule.offset_unit] ?? rule.offset_unit;\n const val = rule.offset_value;\n return `${val} ${unit}${val !== 1 ? 's' : ''} before workshop`;\n }\n\n function esc(str) {\n return String(str).replace(/&/g, '&').replace(//g, '>');\n }\n\n async function loadSchedule() {\n const list = document.getElementById('scheduleList');\n const countEl = document.getElementById('scheduleCount');\n const pastList = document.getElementById('pastList');\n const pastCard = document.getElementById('pastCard');\n const pastCountEl = document.getElementById('pastCount');\n try {\n const data = await fetch('/api/schedule.php').then(r => r.json());\n const rules = data.rules ?? [];\n\n // Store rules by id so edit button can look them up safely\n window._scheduleRules = {};\n rules.forEach(r => { window._scheduleRules[r.id] = r; });\n\n const now = Date.now();\n const upcoming = [];\n const past = [];\n rules.forEach(rule => {\n if (rule.mode === 'specific') {\n const t = new Date(rule.specific_datetime.replace(' ', 'T')).getTime();\n (t > now ? upcoming : past).push(rule);\n } else {\n upcoming.push(rule);\n }\n });\n\n // Sort upcoming: specific rules soonest first, relative rules after\n upcoming.sort((a, b) => {\n const aTime = a.mode === 'specific' ? new Date(a.specific_datetime.replace(' ', 'T')).getTime() : Infinity;\n const bTime = b.mode === 'specific' ? new Date(b.specific_datetime.replace(' ', 'T')).getTime() : Infinity;\n return aTime - bTime;\n });\n\n countEl.textContent = `${upcoming.length} scheduled`;\n\n if (upcoming.length === 0) {\n list.innerHTML = '

No upcoming rules. Add one above.

';\n } else {\n list.innerHTML = upcoming.map(rule => renderRuleCard(rule, false)).join('');\n }\n\n if (past.length > 0) {\n pastCard.style.display = '';\n pastCountEl.textContent = `${past.length} past`;\n pastList.innerHTML = past.map(rule => renderRuleCard(rule, true)).join('');\n } else {\n pastCard.style.display = 'none';\n }\n } catch {\n list.innerHTML = '

Could not load schedule.

';\n }\n }\n\n function renderRuleCard(rule, isPast) {\n const label = ruleLabel(rule);\n const sendAt = rule.mode === 'specific' ? new Date(rule.specific_datetime.replace(' ', 'T')) : null;\n const timeTag = sendAt\n ? isPast\n ? `${formatDateTime(sendAt)}`\n : `${relativeTime(sendAt)}`\n : '';\n const opacity = isPast ? 'opacity:0.6;' : '';\n return `\n
\n
\n
\n
${esc(rule.template_name)}
\n
${esc(label)}
\n
\n
\n ${!isPast ? `` : ``}\n \n
\n
\n ${timeTag}\n
\n `;\n }\n\n loadTemplateOptions();\n loadSchedule();\n setInterval(loadSchedule, 60000);\n\n // Set default datetime-local to now\n const pad = n => String(n).padStart(2, '0');\n const now = new Date();\n document.getElementById('specificDatetime').value =\n `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;\n ", "\n async function checkAuth() {\n try {\n const r = await fetch('/api/admin.php', { redirect: 'manual' });\n if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }\n const d = await r.json();\n document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';\n document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));\n const name = d.name || d.username;\n if (name) {\n document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });\n }\n } catch { }\n }\n checkAuth();\n document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });\n\n async function checkApiKey() {\n try {\n const r = await fetch('/api/settings.php?key=clickatell_api_key').then(res => res.json());\n const missing = !r.set;\n document.getElementById('noApiKeyBanner').style.display = missing ? '' : 'none';\n document.getElementById('addScheduleBtn').disabled = missing;\n } catch { }\n }\n checkApiKey();\n "], "delivery-log.html": ["\n let logTable = null;\n let _lastDataKey = '';\n\n function esc(str) {\n return String(str).replace(/&/g, '&').replace(//g, '>').replace(/\"/g, '"');\n }\n\n function errorDesc(msg) {\n if (!msg) return '';\n try {\n const parsed = JSON.parse(msg);\n // Top-level error (older Clickatell responses)\n if (parsed.error) return parsed.error;\n // Clickatell v2: { messages: [{ error, errorDescription }] }\n const m = Array.isArray(parsed.messages) ? parsed.messages[0] : null;\n if (m?.error) return m.error;\n if (m?.errorDescription) return m.errorDescription;\n return parsed.message || msg;\n } catch {\n return msg;\n }\n }\n\n function fmtDate(str) {\n if (!str) return '\u2014';\n return new Date(str).toLocaleString('en-GB', {\n day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'\n });\n }\n\n async function loadLog(isRefresh = false) {\n const tbody = document.getElementById('logBody');\n\n try {\n const data = await fetch('/api/delivery-log.php?all=1').then(r => r.json());\n const stats = data.stats ?? {};\n const entries = data.entries ?? [];\n\n document.getElementById('stat-sent').textContent = stats.total_sent ?? '\u2014';\n document.getElementById('stat-failed').textContent = stats.total_failed ?? '\u2014';\n document.getElementById('stat-rate').textContent = stats.success_rate != null ? stats.success_rate + '%' : '\u2014';\n\n // Skip expensive DOM rebuild if nothing has changed\n const dataKey = `${entries.length}:${stats.total_sent}:${stats.total_failed}`;\n if (isRefresh && dataKey === _lastDataKey) return;\n _lastDataKey = dataKey;\n\n // Preserve current page position before destroying the table\n const prevPage = logTable ? logTable.page() : 0;\n\n // Destroy existing DataTable before rewriting DOM\n if (logTable) { logTable.destroy(); logTable = null; }\n\n if (entries.length === 0) {\n tbody.innerHTML = `No messages sent yet.`;\n return;\n }\n\n tbody.innerHTML = entries.map(e => {\n const statusBadge = e.status === 'failed'\n ? `Failed`\n : `Sent`;\n return `\n \n ${esc(e.attendee_name ?? '\u2014')}\n ${e.message_body\n ? `${esc(e.template_name)}`\n : esc(e.template_name)}\n ${esc(e.mobile_number)}\n ${statusBadge}\n ${fmtDate(e.sent_at)}\n \n `;\n }).join('');\n\n logTable = new DataTable('#logTable', {\n order: [[4, 'desc']],\n columnDefs: [{ orderable: false, targets: 3 }],\n pageLength: 25,\n drawCallback: () => {\n document.querySelectorAll('[data-bs-toggle=\"tooltip\"]').forEach(el => {\n bootstrap.Tooltip.getOrCreateInstance(el);\n });\n },\n });\n if (isRefresh && prevPage > 0 && prevPage < logTable.page.info().pages) {\n logTable.page(prevPage).draw(false);\n }\n } catch {\n tbody.innerHTML = `Could not load delivery log.`;\n }\n }\n\n loadLog();\n setInterval(() => loadLog(true), 10_000);\n ", "\n async function checkAuth() {\n try {\n const r = await fetch('/api/admin.php', { redirect: 'manual' });\n if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }\n const d = await r.json();\n document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';\n document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));\n const name = d.name || d.username;\n if (name) {\n document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });\n }\n } catch { }\n }\n checkAuth();\n document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });\n "], "send-now.html": ["\n const SEND_MAX = 160;\n\n // GSM-7 basic + extended character set\n const GSM7 = new Set('@\u00a3$\u00a5\u00e8\u00e9\u00f9\u00ec\u00f2\u00c7\\n\u00d8\u00f8\\r\u00c5\u00e5\u0394_\u03a6\u0393\u039b\u03a9\u03a0\u03a8\u03a3\u0398\u039e\u00c6\u00e6\u00df\u00c9 !\"#\u00a4%&\\'()*+,-./0123456789:;<=>?\u00a1ABCDEFGHIJKLMNOPQRSTUVWXYZ\u00c4\u00d6\u00d1\u00dc\u00a7\u00bfabcdefghijklmnopqrstuvwxyz\u00e4\u00f6\u00f1\u00fc\u00e0\u20ac[]{}\\\\^|~');\n const GSM7_EXTENDED = new Set('\u20ac[]{}\\\\^|~');\n\n function smsEncoding(text) {\n const isUnicode = [...text].some(c => !GSM7.has(c));\n const limit = isUnicode ? 70 : 160;\n const length = isUnicode ? text.length : [...text].reduce((n, c) => n + (GSM7_EXTENDED.has(c) ? 2 : 1), 0);\n return { length, limit, isUnicode };\n }\n\n const msgEl = document.getElementById('message');\n const countEl = document.getElementById('charCount');\n const previewEl = document.getElementById('preview');\n const sectionEl = document.getElementById('previewSection');\n const placeholderEl = document.getElementById('previewPlaceholder');\n const sendBtn = document.getElementById('sendBtn');\n const countSpan = document.getElementById('recipientCount');\n const recipientsSel = document.getElementById('recipients');\n const resultEl = document.getElementById('sendResult');\n let noApiKey = false;\n\n // \u2500\u2500 Populate recipients from API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n async function loadRecipients() {\n try {\n const [attData, wsData] = await Promise.all([\n fetch('/api/attendees.php').then(r => r.json()),\n fetch('/api/workshops.php').then(r => r.json()),\n ]);\n const total = attData.total ?? 0;\n const workshops = wsData.workshops ?? [];\n\n const opts = [``];\n // Group sessions by workshop name (case-insensitive)\n const groups = {};\n workshops.forEach(ws => {\n const key = ws.workshop_name.toLowerCase();\n if (!groups[key]) groups[key] = { label: ws.workshop_name, sessions: [] };\n groups[key].sessions.push(ws);\n });\n const fmtTime = ws => ws.workshop_time\n ? new Date(ws.workshop_time.replace(' ', 'T')).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })\n : '(no time)';\n Object.values(groups).forEach(({ label, sessions }) => {\n if (sessions.length === 1) {\n const ws = sessions[0];\n opts.push(``);\n } else {\n opts.push(``);\n sessions.forEach(ws => {\n opts.push(``);\n });\n opts.push(``);\n }\n });\n\n recipientsSel.innerHTML = opts.join('');\n updateCount();\n } catch {\n recipientsSel.innerHTML = '';\n }\n }\n\n function updateCount() {\n const selected = recipientsSel.options[recipientsSel.selectedIndex];\n countSpan.textContent = selected?.dataset.count ?? '0';\n }\n\n recipientsSel.addEventListener('change', updateCount);\n\n // \u2500\u2500 Message compose \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function previewText(raw) {\n return raw\n .replace(/\\{name\\}/gi, 'Jordan')\n .replace(/\\{first[_ ]name\\}/gi, 'Jordan')\n .replace(/\\{workshop\\}/gi, 'Axe Throwing')\n .replace(/\\{workshop_time\\}/gi, '13:30');\n }\n\n function insertPlaceholder(text) {\n const start = msgEl.selectionStart;\n const end = msgEl.selectionEnd;\n const val = msgEl.value;\n msgEl.value = val.slice(0, start) + text + val.slice(end);\n msgEl.selectionStart = msgEl.selectionEnd = start + text.length;\n msgEl.focus();\n msgEl.dispatchEvent(new Event('input'));\n }\n\n msgEl.addEventListener('input', function () {\n const val = this.value;\n const { length, limit, isUnicode } = smsEncoding(val);\n const remaining = limit - length;\n\n countEl.innerHTML = `${length}/${limit} characters${isUnicode ? '   Unicode encoding \u2014 limit reduced to 70' : ''}`;\n countEl.className = `d-block mb-2 ${remaining <= 10 ? (remaining <= 0 ? 'text-danger' : 'text-warning') : 'text-muted'}`;\n\n const empty = val.trim() === '';\n sectionEl.style.visibility = empty ? 'hidden' : 'visible';\n placeholderEl.style.display = empty ? '' : 'none';\n previewEl.textContent = empty ? '' : previewText(val);\n sendBtn.disabled = empty || noApiKey;\n resultEl.style.display = 'none';\n });\n\n // \u2500\u2500 Send \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n function esc(str) {\n return String(str).replace(/&/g, '&').replace(//g, '>');\n }\n\n sendBtn.addEventListener('click', async () => {\n const message = msgEl.value.trim();\n if (!message) return;\n\n const selVal = recipientsSel.value;\n const isAll = selVal === 'all';\n const wsId = isAll ? null : parseInt(selVal.split(':')[1]);\n\n sendBtn.disabled = true;\n sendBtn.innerHTML = 'Sending\u2026';\n resultEl.style.display = 'none';\n\n try {\n const selectedTemplateId = templatePicker.value ? parseInt(templatePicker.value) : null;\n const payload = {\n recipient_scope: isAll ? 'all' : 'workshop',\n };\n if (selectedTemplateId) {\n payload.template_id = selectedTemplateId;\n } else {\n payload.message = message;\n }\n if (!isAll) payload.workshop_session_id = wsId;\n\n const res = await fetch('/api/send-now.php', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(payload),\n });\n const data = await res.json();\n\n if (!res.ok) throw new Error(data.error ?? 'Unknown error');\n\n resultEl.style.display = '';\n resultEl.innerHTML = `\n
\n \n Sent to ${data.sent} recipient${data.sent !== 1 ? 's' : ''}.\n
`;\n msgEl.value = '';\n msgEl.dispatchEvent(new Event('input'));\n } catch (err) {\n resultEl.style.display = '';\n resultEl.innerHTML = `\n
\n ${esc(err.message)}\n
`;\n } finally {\n sendBtn.disabled = msgEl.value.trim() === '' || noApiKey;\n sendBtn.innerHTML = 'Send to ' + countSpan.textContent + ' attendees';\n }\n });\n\n loadRecipients();\n\n // \u2500\u2500 Template picker \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n const templatePicker = document.getElementById('templatePicker');\n let templateBodies = {}; // id \u2192 body\n\n async function loadTemplatePicker() {\n try {\n const data = await fetch('/api/templates.php').then(r => r.json());\n const templates = data.templates ?? [];\n templateBodies = {};\n templates.forEach(t => { templateBodies[t.id] = t.body; });\n templatePicker.innerHTML = ''\n + templates.map(t => ``).join('');\n } catch {\n // leave the default option in place\n }\n }\n\n templatePicker.addEventListener('change', () => {\n const body = templateBodies[templatePicker.value];\n if (body !== undefined) {\n msgEl.value = body;\n msgEl.dispatchEvent(new Event('input'));\n msgEl.focus();\n }\n });\n\n loadTemplatePicker();\n ", "\n async function checkAuth() {\n try {\n const r = await fetch('/api/admin.php', { redirect: 'manual' });\n if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }\n const d = await r.json();\n document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';\n document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));\n const name = d.name || d.username;\n if (name) {\n document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });\n }\n } catch { }\n }\n checkAuth();\n document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });\n\n async function checkApiKey() {\n try {\n const r = await fetch('/api/settings.php?key=clickatell_api_key').then(res => res.json());\n noApiKey = !r.set;\n document.getElementById('noApiKeyBanner').style.display = noApiKey ? '' : 'none';\n if (noApiKey) document.getElementById('sendBtn').disabled = true;\n } catch { }\n }\n checkApiKey();\n "], "admin.html": ["\n 'use strict';\n\n // \u2500\u2500\u2500 Load admin state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n async function loadAdmin() {\n try {\n const r = await fetch('/api/admin.php', { redirect: 'manual' });\n if (r.type === 'opaqueredirect') { window.location.reload(); return; }\n if (!r.ok) { window.location.replace('index.html'); return; }\n const data = await r.json();\n\n if (!data.is_admin) {\n window.location.replace('index.html');\n return;\n }\n\n document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.remove('d-none'));\n const name = data.name || data.username;\n if (name) {\n document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });\n }\n\n document.getElementById('sandboxToggle').checked = data.sandbox_mode;\n document.getElementById('sandboxBanner').style.display = data.sandbox_mode ? '' : 'none';\n\n document.getElementById('count-log').textContent = data.delivery_log_total ?? '\u2014';\n document.getElementById('count-attendees').textContent = data.attendees ?? '\u2014';\n document.getElementById('count-templates').textContent = data.templates ?? '\u2014';\n document.getElementById('count-schedules').textContent = data.schedules ?? '\u2014';\n } catch (e) {\n showResult('Failed to load admin data: ' + e.message, 'danger');\n }\n }\n\n // \u2500\u2500\u2500 Sandbox toggle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n document.getElementById('sandboxToggle').addEventListener('change', async () => {\n try {\n const data = await fetch('/api/admin.php', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ action: 'toggle_sandbox' }),\n }).then(r => r.json());\n document.getElementById('sandboxToggle').checked = data.sandbox_mode;\n document.getElementById('sandboxBanner').style.display = data.sandbox_mode ? '' : 'none';\n showResult(`Sandbox mode ${data.sandbox_mode ? 'enabled' : 'disabled'}.`, 'success');\n } catch (e) {\n showResult('Failed to toggle sandbox mode: ' + e.message, 'danger');\n await loadAdmin(); // revert toggle visual state\n }\n });\n\n // \u2500\u2500\u2500 Mark all as sandbox \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n async function markAllSandbox() {\n if (!confirm('Mark ALL existing delivery log entries as sandbox?\\n\\nThey will be excluded from cost calculations and success rate on the dashboard.')) {\n return;\n }\n try {\n const res = await fetch('/api/admin.php', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({ action: 'mark_all_sandbox' }),\n });\n if (!res.ok) throw new Error((await res.json()).error ?? 'Failed');\n showResult('All sends marked as sandbox.', 'success');\n loadAdmin();\n } catch (e) {\n showResult('Failed: ' + e.message, 'danger');\n }\n }\n\n // \u2500\u2500\u2500 Purge \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n const PURGE_LABELS = {\n delivery_log: 'all delivery log entries',\n attendees: 'all attendees, registrations and workshops',\n templates: 'all SMS templates',\n schedules: 'all scheduled rules',\n };\n\n async function purge(target, useDateFilter = false) {\n const body = { action: 'purge', target };\n\n if (useDateFilter) {\n const before = document.getElementById('purgeBefore').value;\n if (!before) {\n alert('Please select a date first.');\n return;\n }\n if (!confirm(`Purge all delivery log entries before ${before}?\\n\\nThis cannot be undone.`)) {\n return;\n }\n body.before = before;\n } else {\n if (!confirm(`This will permanently delete ${PURGE_LABELS[target]}.\\n\\nThis cannot be undone. Are you sure?`)) {\n return;\n }\n }\n\n try {\n const res = await fetch('/api/admin.php', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(body),\n });\n const data = await res.json();\n if (!res.ok) throw new Error(data.error ?? 'Purge failed');\n const n = typeof data.purged === 'number' ? ` (${data.purged} rows)` : '';\n showResult(`Purge completed successfully${n}.`, 'success');\n loadAdmin();\n } catch (e) {\n showResult('Purge failed: ' + e.message, 'danger');\n }\n }\n\n // \u2500\u2500\u2500 Toast/alert helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n function showResult(msg, type) {\n const el = document.getElementById('adminResult');\n el.className = `alert alert-${type}`;\n el.textContent = msg;\n el.style.display = '';\n clearTimeout(el._timer);\n el._timer = setTimeout(() => { el.style.display = 'none'; }, 6000);\n }\n\n // \u2500\u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n loadAdmin();\n document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') loadAdmin(); });\n "]}; function runScriptBlock(code) { const funcNames = [...code.matchAll(/^\s*(?:async\s+)?function\s+(\w+)\s*\(/gm)].map(m => m[1]); const expose = funcNames.map(n => `try{window.${n}=${n};}catch(_){}`).join('\n'); const s = document.createElement('script'); s.textContent = `(function(){\n${code}\n${expose}\n})();`; document.head.appendChild(s); s.remove(); } function runPageScripts(page) { const blocks = PAGE_SCRIPTS[page] || []; for (const code of blocks) runScriptBlock(code); } window.__runPageScripts = runPageScripts; const page = location.pathname.split("/").pop() || "index.html"; runPageScripts(page); })();