Compare commits
10 Commits
a0bff2beb1
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ac6041b34 | |||
| 04849f5c6f | |||
| ed65314fa5 | |||
| 4cefa3be76 | |||
| 1140f87bf5 | |||
| 8834feb5a1 | |||
| b2b92c2311 | |||
| e7e23a11b5 | |||
| 027ac59bc7 | |||
| 458b7897dd |
+35
-203
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>Admin — IMFestival SMS Dashboard</title>
|
<title>Admin — IMFestival SMS Dashboard</title>
|
||||||
|
|
||||||
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||||
@@ -14,51 +14,13 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- ─── Mobile top bar ─── -->
|
<!-- ─── Mobile top bar ─── -->
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
<span style="font-weight:600;font-size:0.95rem;">Admin</span>
|
||||||
<i class="fa-solid fa-bars"></i>
|
<span class="nav-username ms-auto" style="font-size:0.82rem;color:var(--text-muted);">User</span>
|
||||||
</button>
|
</div>
|
||||||
<span style="font-weight:600;font-size:0.95rem;">Admin</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ─── Mobile offcanvas sidebar ─── -->
|
<!-- ─── Desktop sidebar ─── -->
|
||||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
|
||||||
<div class="offcanvas-header">
|
|
||||||
<div class="brand"
|
|
||||||
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
|
||||||
<span>SMS Dashboard</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas"
|
|
||||||
aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="offcanvas-body p-3">
|
|
||||||
<nav class="d-flex flex-column gap-1">
|
|
||||||
<a href="index.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-house fa-fw"></i>
|
|
||||||
Home
|
|
||||||
</a>
|
|
||||||
<a href="attendees.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-users fa-fw"></i>
|
|
||||||
Attendees
|
|
||||||
</a>
|
|
||||||
<a href="templates.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
|
||||||
SMS Templates
|
|
||||||
</a>
|
|
||||||
<a href="schedule.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
|
||||||
Schedule
|
|
||||||
</a>
|
|
||||||
<a href="delivery-log.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
|
||||||
Delivery Log
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ─── Desktop sidebar ─── -->
|
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
|
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
|
||||||
@@ -137,28 +99,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats row -->
|
|
||||||
<div class="row g-3 mb-3">
|
|
||||||
<div class="col-4">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">Real Sends</div>
|
|
||||||
<div class="stat-value" id="stat-real" style="color:#4ade80;">—</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">Sandbox Sends</div>
|
|
||||||
<div class="stat-value" id="stat-sandbox" style="color:var(--text-muted);">—</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">Total Logged</div>
|
|
||||||
<div class="stat-value" id="stat-total">—</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn btn-sm btn-secondary" onclick="markAllSandbox()">
|
<button class="btn btn-sm btn-secondary" onclick="markAllSandbox()">
|
||||||
<i class="fa-solid fa-flask me-1"></i>
|
<i class="fa-solid fa-flask me-1"></i>
|
||||||
Mark all existing sends as sandbox
|
Mark all existing sends as sandbox
|
||||||
@@ -237,144 +177,36 @@
|
|||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
|
<script src="assets/js/nav.js"></script>
|
||||||
|
<script src="assets/js/app.js"></script>
|
||||||
|
|
||||||
<script>
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// ─── Load admin state ────────────────────────────────────────────────────────
|
<nav class="bottom-nav" aria-label="Navigation">
|
||||||
|
<a href="index.html" class="bottom-nav-item">
|
||||||
async function loadAdmin() {
|
<i class="fa-solid fa-house"></i>
|
||||||
try {
|
<span>Home</span>
|
||||||
const r = await fetch('/api/admin.php', { redirect: 'manual' });
|
</a>
|
||||||
if (r.type === 'opaqueredirect') { window.location.reload(); return; }
|
<a href="attendees.html" class="bottom-nav-item">
|
||||||
if (!r.ok) { window.location.replace('index.html'); return; }
|
<i class="fa-solid fa-users"></i>
|
||||||
const data = await r.json();
|
<span>Attendees</span>
|
||||||
|
</a>
|
||||||
if (!data.is_admin) {
|
<a href="templates.html" class="bottom-nav-item">
|
||||||
window.location.replace('index.html');
|
<i class="fa-solid fa-comment-sms"></i>
|
||||||
return;
|
<span>Templates</span>
|
||||||
}
|
</a>
|
||||||
|
<a href="schedule.html" class="bottom-nav-item">
|
||||||
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.remove('d-none'));
|
<i class="fa-solid fa-calendar-days"></i>
|
||||||
const name = data.name || data.username;
|
<span>Schedule</span>
|
||||||
if (name) {
|
</a>
|
||||||
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
|
<a href="delivery-log.html" class="bottom-nav-item">
|
||||||
}
|
<i class="fa-solid fa-list-check"></i>
|
||||||
|
<span>Log</span>
|
||||||
document.getElementById('sandboxToggle').checked = data.sandbox_mode;
|
</a>
|
||||||
document.getElementById('sandboxBanner').style.display = data.sandbox_mode ? '' : 'none';
|
<a href="admin.html" class="bottom-nav-item active admin-nav-link d-none">
|
||||||
|
<i class="fa-solid fa-user-shield"></i>
|
||||||
document.getElementById('stat-real').textContent = data.delivery_log_real_sent ?? '—';
|
<span>Admin</span>
|
||||||
document.getElementById('stat-sandbox').textContent = data.delivery_log_sandbox ?? '—';
|
</a>
|
||||||
document.getElementById('stat-total').textContent = data.delivery_log_total ?? '—';
|
</nav>
|
||||||
|
|
||||||
document.getElementById('count-log').textContent = data.delivery_log_total ?? '—';
|
|
||||||
document.getElementById('count-attendees').textContent = data.attendees ?? '—';
|
|
||||||
document.getElementById('count-templates').textContent = data.templates ?? '—';
|
|
||||||
document.getElementById('count-schedules').textContent = data.schedules ?? '—';
|
|
||||||
} catch (e) {
|
|
||||||
showResult('Failed to load admin data: ' + e.message, 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Sandbox toggle ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
document.getElementById('sandboxToggle').addEventListener('change', async () => {
|
|
||||||
try {
|
|
||||||
const data = await fetch('/api/admin.php', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action: 'toggle_sandbox' }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
document.getElementById('sandboxToggle').checked = data.sandbox_mode;
|
|
||||||
document.getElementById('sandboxBanner').style.display = data.sandbox_mode ? '' : 'none';
|
|
||||||
showResult(`Sandbox mode ${data.sandbox_mode ? 'enabled' : 'disabled'}.`, 'success');
|
|
||||||
} catch (e) {
|
|
||||||
showResult('Failed to toggle sandbox mode: ' + e.message, 'danger');
|
|
||||||
await loadAdmin(); // revert toggle visual state
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── Mark all as sandbox ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function markAllSandbox() {
|
|
||||||
if (!confirm('Mark ALL existing delivery log entries as sandbox?\n\nThey will be excluded from cost calculations and success rate on the dashboard.')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin.php', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ action: 'mark_all_sandbox' }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error((await res.json()).error ?? 'Failed');
|
|
||||||
showResult('All sends marked as sandbox.', 'success');
|
|
||||||
loadAdmin();
|
|
||||||
} catch (e) {
|
|
||||||
showResult('Failed: ' + e.message, 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Purge ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const PURGE_LABELS = {
|
|
||||||
delivery_log: 'all delivery log entries',
|
|
||||||
attendees: 'all attendees, registrations and workshops',
|
|
||||||
templates: 'all SMS templates',
|
|
||||||
schedules: 'all scheduled rules',
|
|
||||||
};
|
|
||||||
|
|
||||||
async function purge(target, useDateFilter = false) {
|
|
||||||
const body = { action: 'purge', target };
|
|
||||||
|
|
||||||
if (useDateFilter) {
|
|
||||||
const before = document.getElementById('purgeBefore').value;
|
|
||||||
if (!before) {
|
|
||||||
alert('Please select a date first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!confirm(`Purge all delivery log entries before ${before}?\n\nThis cannot be undone.`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
body.before = before;
|
|
||||||
} else {
|
|
||||||
if (!confirm(`This will permanently delete ${PURGE_LABELS[target]}.\n\nThis cannot be undone. Are you sure?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/admin.php', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error ?? 'Purge failed');
|
|
||||||
const n = typeof data.purged === 'number' ? ` (${data.purged} rows)` : '';
|
|
||||||
showResult(`Purge completed successfully${n}.`, 'success');
|
|
||||||
loadAdmin();
|
|
||||||
} catch (e) {
|
|
||||||
showResult('Purge failed: ' + e.message, 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Toast/alert helper ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function showResult(msg, type) {
|
|
||||||
const el = document.getElementById('adminResult');
|
|
||||||
el.className = `alert alert-${type}`;
|
|
||||||
el.textContent = msg;
|
|
||||||
el.style.display = '';
|
|
||||||
clearTimeout(el._timer);
|
|
||||||
el._timer = setTimeout(() => { el.style.display = 'none'; }, 6000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Init ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
loadAdmin();
|
|
||||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') loadAdmin(); });
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__ . '/db.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
error_out('Method not allowed', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = db();
|
||||||
|
$row = $db->query(
|
||||||
|
"SELECT setting_value FROM `system` WHERE setting_name = 'clickatell_api_key'"
|
||||||
|
)->fetch();
|
||||||
|
|
||||||
|
$api_key = $row['setting_value'] ?? '';
|
||||||
|
if ($api_key === '') {
|
||||||
|
json_out(['error' => 'API key not configured', 'balance' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ch = curl_init('https://platform.clickatell.com/public-client/balance');
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
CURLOPT_HTTPHEADER => [
|
||||||
|
'Authorization: ' . $api_key,
|
||||||
|
'Accept: application/json',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$err = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($err !== '') {
|
||||||
|
json_out(['error' => 'Request failed: ' . $err, 'balance' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($body ?: '', true);
|
||||||
|
|
||||||
|
if ($status !== 200 || $data === null) {
|
||||||
|
json_out(['error' => 'Unexpected response (HTTP ' . $status . ')', 'balance' => null, 'raw' => $body]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$credit = null;
|
||||||
|
$currency = $data['currency'] ?? null;
|
||||||
|
|
||||||
|
if (isset($data['balance']) && is_numeric($data['balance'])) {
|
||||||
|
// Flat format: {"balance": 89.99, "currency": "GBP"}
|
||||||
|
$credit = $data['balance'];
|
||||||
|
} elseif (isset($data['balance']) && is_array($data['balance']) && isset($data['balance'][0]['credit'])) {
|
||||||
|
// Array format: {"balance": [{"credit": "99.50", ...}]}
|
||||||
|
$credit = $data['balance'][0]['credit'];
|
||||||
|
$currency = $currency ?? ($data['balance'][0]['currency'] ?? null);
|
||||||
|
} elseif (isset($data['credit'])) {
|
||||||
|
$credit = $data['credit'];
|
||||||
|
} elseif (isset($data['data']['credit'])) {
|
||||||
|
$credit = $data['data']['credit'];
|
||||||
|
}
|
||||||
|
|
||||||
|
json_out(['balance' => $credit, 'currency' => $currency]);
|
||||||
@@ -61,3 +61,16 @@ function error_out(string $message, int $status = 400): never
|
|||||||
{
|
{
|
||||||
json_out(['error' => $message], $status);
|
json_out(['error' => $message], $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aborts with 400 if no Clickatell API key has been configured.
|
||||||
|
*/
|
||||||
|
function require_api_key(): void
|
||||||
|
{
|
||||||
|
$stmt = db()->prepare("SELECT setting_value FROM `system` WHERE setting_name = 'clickatell_api_key'");
|
||||||
|
$stmt->execute();
|
||||||
|
$key = $stmt->fetchColumn();
|
||||||
|
if ($key === false || trim((string) $key) === '') {
|
||||||
|
error_out('No Clickatell API key is configured. Set one in Admin → Clickatell Settings before sending or scheduling messages.', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ function handle_get(): never
|
|||||||
|
|
||||||
function handle_post(): never
|
function handle_post(): never
|
||||||
{
|
{
|
||||||
|
require_api_key();
|
||||||
|
|
||||||
$body = json_body();
|
$body = json_body();
|
||||||
$templateId = (int) ($body['template_id'] ?? 0);
|
$templateId = (int) ($body['template_id'] ?? 0);
|
||||||
$mode = (string) ($body['mode'] ?? '');
|
$mode = (string) ($body['mode'] ?? '');
|
||||||
@@ -104,6 +106,8 @@ function handle_post(): never
|
|||||||
|
|
||||||
function handle_put(): never
|
function handle_put(): never
|
||||||
{
|
{
|
||||||
|
require_api_key();
|
||||||
|
|
||||||
$id = (int) ($_GET['id'] ?? 0);
|
$id = (int) ($_GET['id'] ?? 0);
|
||||||
if ($id <= 0) {
|
if ($id <= 0) {
|
||||||
error_out('Missing or invalid id');
|
error_out('Missing or invalid id');
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
require_api_key();
|
||||||
|
|
||||||
$body = json_body();
|
$body = json_body();
|
||||||
$customBody = trim((string) ($body['message'] ?? ''));
|
$customBody = trim((string) ($body['message'] ?? ''));
|
||||||
$templateId = isset($body['template_id']) ? (int) $body['template_id'] : null;
|
$templateId = isset($body['template_id']) ? (int) $body['template_id'] : null;
|
||||||
|
|||||||
@@ -112,6 +112,43 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Bottom nav (mobile) ── */
|
||||||
|
.bottom-nav {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0; left: 0; right: 0;
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
z-index: 1040;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 0.5rem max(0.5rem, env(safe-area-inset-left)) calc(0.5rem + env(safe-area-inset-bottom)) max(0.5rem, env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav a {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
transition: color 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav a i {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav a.active,
|
||||||
|
.bottom-nav a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Top bar (mobile) ── */
|
/* ── Top bar (mobile) ── */
|
||||||
.topbar {
|
.topbar {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -408,7 +445,14 @@ body {
|
|||||||
|
|
||||||
/* ── Responsive ── */
|
/* ── Responsive ── */
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.sidebar { display: none; }
|
.sidebar { display: none; }
|
||||||
.topbar { display: flex; }
|
.bottom-nav { display: flex; }
|
||||||
.main { margin-left: 0; padding: 1rem; }
|
.topbar { display: flex; padding-left: max(1rem, env(safe-area-inset-left)); padding-right: max(1rem, env(safe-area-inset-right)); }
|
||||||
|
.main {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-top: 1rem;
|
||||||
|
padding-left: max(1rem, env(safe-area-inset-left));
|
||||||
|
padding-right: max(1rem, env(safe-area-inset-right));
|
||||||
|
padding-bottom: calc(4rem + max(0.5rem, env(safe-area-inset-bottom)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,211 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Track setIntervals so navigation can clear them ───────────────────────
|
||||||
|
const _origSetInterval = window.setInterval.bind(window);
|
||||||
|
const _origClearInterval = window.clearInterval.bind(window);
|
||||||
|
let _intervals = [];
|
||||||
|
|
||||||
|
window.setInterval = function (fn, ms, ...args) {
|
||||||
|
const id = _origSetInterval(fn, ms, ...args);
|
||||||
|
_intervals.push(id);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
function clearTrackedIntervals() {
|
||||||
|
_intervals.forEach(id => _origClearInterval(id));
|
||||||
|
_intervals = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cleanup state left behind by the outgoing page ────────────────────────
|
||||||
|
function cleanupPage() {
|
||||||
|
clearTrackedIntervals();
|
||||||
|
|
||||||
|
// DataTables 2.x
|
||||||
|
if (window.DataTable) {
|
||||||
|
try { DataTable.tables({ api: true }).destroy(); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dropzone
|
||||||
|
if (window.Dropzone) {
|
||||||
|
try { [...Dropzone.instances].forEach(dz => dz.destroy()); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap modals – hide any open one and remove leftover backdrops
|
||||||
|
document.querySelectorAll('.modal.show').forEach(el => {
|
||||||
|
try { bootstrap.Modal.getInstance(el)?.hide(); } catch (_) {}
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.modal-backdrop').forEach(el => el.remove());
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
document.body.style.removeProperty('padding-right');
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapPageModals(doc) {
|
||||||
|
document.querySelectorAll('[data-ajax-modal="true"]').forEach(el => el.remove());
|
||||||
|
doc.querySelectorAll('body > .modal').forEach(old => {
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.innerHTML = old.outerHTML;
|
||||||
|
const modal = wrapper.firstElementChild;
|
||||||
|
if (!modal) return;
|
||||||
|
modal.setAttribute('data-ajax-modal', 'true');
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-page inline <style> management ───────────────────────────────────
|
||||||
|
const STYLE_ID = 'ajax-page-style';
|
||||||
|
function setPageStyles(cssText) {
|
||||||
|
let tag = document.getElementById(STYLE_ID);
|
||||||
|
if (!cssText) { tag?.remove(); return; }
|
||||||
|
if (!tag) {
|
||||||
|
tag = document.createElement('style');
|
||||||
|
tag.id = STYLE_ID;
|
||||||
|
document.head.appendChild(tag);
|
||||||
|
}
|
||||||
|
tag.textContent = cssText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load external <script src> tags not already on the page ──────────────
|
||||||
|
const _loadedSrcs = new Set(
|
||||||
|
[...document.querySelectorAll('script[src]')].map(s => s.src)
|
||||||
|
);
|
||||||
|
|
||||||
|
function loadScript(src) {
|
||||||
|
const abs = new URL(src, location.href).href;
|
||||||
|
if (_loadedSrcs.has(abs)) return Promise.resolve();
|
||||||
|
_loadedSrcs.add(abs);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = src;
|
||||||
|
s.onload = resolve;
|
||||||
|
s.onerror = reject;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load external <link rel="stylesheet"> tags not already on the page ───
|
||||||
|
const _loadedHrefs = new Set(
|
||||||
|
[...document.querySelectorAll('link[rel="stylesheet"]')].map(l => l.href)
|
||||||
|
);
|
||||||
|
|
||||||
|
function loadStylesheet(href) {
|
||||||
|
const abs = new URL(href, location.href).href;
|
||||||
|
if (_loadedHrefs.has(abs)) return Promise.resolve();
|
||||||
|
_loadedHrefs.add(abs);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const l = document.createElement('link');
|
||||||
|
l.rel = 'stylesheet';
|
||||||
|
l.href = href;
|
||||||
|
l.onload = resolve;
|
||||||
|
l.onerror = reject;
|
||||||
|
document.head.appendChild(l);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Re-execute inline scripts from the fetched document ──────────────────
|
||||||
|
function runInlineScripts(doc) {
|
||||||
|
doc.querySelectorAll('body script:not([src])').forEach(old => {
|
||||||
|
const code = old.textContent;
|
||||||
|
|
||||||
|
// Wrap in an IIFE so top-level `let`/`const` are function-scoped and
|
||||||
|
// don't collide with bindings from a previous visit to the same page.
|
||||||
|
// Then re-expose every named function declaration to `window` so that
|
||||||
|
// inline onclick="foo()" handlers continue to resolve them globally.
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update which nav link is highlighted as active ────────────────────────
|
||||||
|
function setActiveNav(filename) {
|
||||||
|
document.querySelectorAll('.nav-link-custom, .bottom-nav a').forEach(el => {
|
||||||
|
const hf = (el.getAttribute('href') || '').split('/').pop();
|
||||||
|
el.classList.toggle('active', hf === filename);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core AJAX navigation ──────────────────────────────────────────────────
|
||||||
|
let _navigating = false;
|
||||||
|
|
||||||
|
async function navigateTo(href) {
|
||||||
|
if (_navigating) return;
|
||||||
|
_navigating = true;
|
||||||
|
|
||||||
|
const mainInner = document.querySelector('.main-inner');
|
||||||
|
if (!mainInner) { window.location.href = href; return; }
|
||||||
|
|
||||||
|
// Dim content while loading
|
||||||
|
mainInner.style.transition = 'opacity 0.12s';
|
||||||
|
mainInner.style.opacity = '0.35';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(href, { redirect: 'manual' });
|
||||||
|
if (r.type === 'opaqueredirect') { window.location.reload(); return; }
|
||||||
|
if (!r.ok) { window.location.href = href; return; }
|
||||||
|
|
||||||
|
const text = await r.text();
|
||||||
|
const doc = new DOMParser().parseFromString(text, 'text/html');
|
||||||
|
|
||||||
|
// Load any external libs/styles the new page needs that aren't already loaded.
|
||||||
|
// Scripts must load sequentially (order matters: e.g. jQuery before DataTables).
|
||||||
|
// Stylesheets have no dependency order so can load in parallel.
|
||||||
|
for (const s of doc.querySelectorAll('script[src]')) {
|
||||||
|
await loadScript(s.getAttribute('src'));
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
[...doc.querySelectorAll('link[rel="stylesheet"]')].map(l => loadStylesheet(l.getAttribute('href')))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Swap page-specific inline styles
|
||||||
|
const styles = [...doc.head.querySelectorAll('style')].map(s => s.textContent).join('\n');
|
||||||
|
setPageStyles(styles || null);
|
||||||
|
|
||||||
|
// Cleanup outgoing page, then inject new content
|
||||||
|
cleanupPage();
|
||||||
|
mainInner.innerHTML = doc.querySelector('.main-inner')?.innerHTML ?? '';
|
||||||
|
swapPageModals(doc);
|
||||||
|
document.title = doc.title;
|
||||||
|
|
||||||
|
const filename = href.split('/').pop();
|
||||||
|
setActiveNav(filename);
|
||||||
|
|
||||||
|
// Execute page scripts from the centralized app bundle when available.
|
||||||
|
if (typeof window.__runPageScripts === 'function') {
|
||||||
|
window.__runPageScripts(filename);
|
||||||
|
} else {
|
||||||
|
// Fallback for legacy pages that still carry inline scripts.
|
||||||
|
runInlineScripts(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
// Fallback to hard navigation on any error
|
||||||
|
window.location.href = href;
|
||||||
|
} finally {
|
||||||
|
mainInner.style.opacity = '1';
|
||||||
|
_navigating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Intercept all local .html link clicks ────────────────────────────────
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const link = e.target.closest('a[href]');
|
||||||
|
if (!link) return;
|
||||||
|
const href = link.getAttribute('href');
|
||||||
|
if (!href || href.startsWith('#') || href.includes('://') || !href.endsWith('.html')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
navigateTo(href);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
// History API intentionally omitted — no pushState/popstate.
|
||||||
|
|
||||||
|
})();
|
||||||
+36
-501
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>Attendees — IMFestival SMS Dashboard</title>
|
<title>Attendees — IMFestival SMS Dashboard</title>
|
||||||
|
|
||||||
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||||
@@ -16,52 +16,13 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- ─── Mobile top bar ─── -->
|
<!-- ─── Mobile top bar ─── -->
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
<span style="font-weight:600;font-size:0.95rem;">Attendees</span>
|
||||||
<i class="fa-solid fa-bars"></i>
|
<span class="nav-username ms-auto" style="font-size:0.82rem;color:var(--text-muted);">User</span>
|
||||||
</button>
|
</div>
|
||||||
<span style="font-weight:600;font-size:0.95rem;">Attendees</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ─── Mobile offcanvas sidebar ─── -->
|
<!-- ─── Desktop sidebar ─── -->
|
||||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
|
||||||
<div class="offcanvas-header">
|
|
||||||
<div class="brand"
|
|
||||||
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
|
||||||
<img src="assets/img/logo.png" alt="Logo" style="width:120px;height:auto;">
|
|
||||||
<span>SMS Dashboard</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas"
|
|
||||||
aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="offcanvas-body p-3">
|
|
||||||
<nav class="d-flex flex-column gap-1">
|
|
||||||
<a href="index.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-house fa-fw"></i>
|
|
||||||
Home
|
|
||||||
</a>
|
|
||||||
<a href="attendees.html" class="nav-link-custom active">
|
|
||||||
<i class="fa-solid fa-users fa-fw"></i>
|
|
||||||
Attendees
|
|
||||||
</a>
|
|
||||||
<a href="templates.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
|
||||||
SMS Templates
|
|
||||||
</a>
|
|
||||||
<a href="schedule.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
|
||||||
Schedule
|
|
||||||
</a>
|
|
||||||
<a href="delivery-log.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
|
||||||
Delivery Log
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ─── Desktop sidebar ─── -->
|
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
|
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
|
||||||
@@ -233,449 +194,13 @@
|
|||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
|
<script src="assets/js/nav.js"></script>
|
||||||
|
<script src="assets/js/app.js"></script>
|
||||||
<script src="https://unpkg.com/dropzone@6.0.0-beta.1/dist/dropzone-min.js"></script>
|
<script src="https://unpkg.com/dropzone@6.0.0-beta.1/dist/dropzone-min.js"></script>
|
||||||
<script src="https://code.jquery.com/jquery-4.0.0.min.js"
|
<script src="https://code.jquery.com/jquery-4.0.0.min.js"
|
||||||
integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
|
integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
|
||||||
<script src="https://cdn.datatables.net/2.3.8/js/dataTables.min.js"></script>
|
<script src="https://cdn.datatables.net/2.3.8/js/dataTables.min.js"></script>
|
||||||
<script>
|
|
||||||
Dropzone.autoDiscover = false;
|
|
||||||
|
|
||||||
let pendingFile = null;
|
|
||||||
let currentDuplicates = [];
|
|
||||||
|
|
||||||
const dz = new Dropzone("#csvDropzone", {
|
|
||||||
url: "/api/attendees.php",
|
|
||||||
acceptedFiles: ".csv",
|
|
||||||
maxFiles: 1,
|
|
||||||
maxFilesize: 5,
|
|
||||||
autoProcessQueue: false,
|
|
||||||
dictDefaultMessage: "",
|
|
||||||
params: () => ({ mode: document.querySelector('input[name="importMode"]:checked').value }),
|
|
||||||
});
|
|
||||||
|
|
||||||
dz.on("addedfile", (file) => {
|
|
||||||
document.getElementById('confirmUploadFilename').textContent = file.name;
|
|
||||||
document.getElementById('confirmUploadBar').classList.remove('d-none');
|
|
||||||
document.getElementById('importResult').innerHTML = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
dz.on("removedfile", () => {
|
|
||||||
document.getElementById('confirmUploadBar').classList.add('d-none');
|
|
||||||
document.getElementById('confirmUploadFilename').textContent = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
function confirmUpload() {
|
|
||||||
dz.processQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelUpload() {
|
|
||||||
dz.removeAllFiles();
|
|
||||||
}
|
|
||||||
|
|
||||||
dz.on("success", (file, response) => {
|
|
||||||
const r = typeof response === 'string' ? JSON.parse(response) : response;
|
|
||||||
if (r.status === 'needs_resolution') {
|
|
||||||
pendingFile = file;
|
|
||||||
currentDuplicates = r.duplicates;
|
|
||||||
showDuplicateModal(r.duplicates);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dz.removeFile(file);
|
|
||||||
const resultEl = document.getElementById('importResult');
|
|
||||||
const parts = [];
|
|
||||||
if (r.imported) parts.push(`<span class="text-success">${r.imported} added</span>`);
|
|
||||||
if (r.updated) parts.push(`<span class="text-info">${r.updated} updated</span>`);
|
|
||||||
if (r.skipped) {
|
|
||||||
const reasons = [];
|
|
||||||
if (r.skip_reasons?.not_attending) reasons.push(`${r.skip_reasons.not_attending} not attending`);
|
|
||||||
if (r.skip_reasons?.no_mobile) reasons.push(`${r.skip_reasons.no_mobile} invalid/missing mobile`);
|
|
||||||
const detail = reasons.length ? ` (${reasons.join(', ')})` : '';
|
|
||||||
parts.push(`<span class="text-muted">${r.skipped} skipped${detail}</span>`);
|
|
||||||
}
|
|
||||||
resultEl.innerHTML = parts.length ? 'Import complete: ' + parts.join(', ') + '.' : 'Import complete.';
|
|
||||||
if (r.rejected && r.rejected.length > 0) {
|
|
||||||
showRejectedRecords(r.rejected);
|
|
||||||
} else {
|
|
||||||
rejectedRows = [];
|
|
||||||
renderRejectedTable();
|
|
||||||
}
|
|
||||||
loadAttendees();
|
|
||||||
});
|
|
||||||
|
|
||||||
dz.on("error", (file, msg) => {
|
|
||||||
const errText = typeof msg === 'object' ? (msg.error ?? JSON.stringify(msg)) : msg;
|
|
||||||
document.getElementById('importResult').innerHTML = `<span class="text-danger">Import failed: ${esc(errText)}</span>`;
|
|
||||||
dz.removeFile(file);
|
|
||||||
});
|
|
||||||
|
|
||||||
let table = null;
|
|
||||||
|
|
||||||
async function loadAttendees() {
|
|
||||||
const tbody = document.getElementById('attendeeBody');
|
|
||||||
const countEl = document.getElementById('attendeeCount');
|
|
||||||
|
|
||||||
// Destroy existing DataTable instance before touching the DOM
|
|
||||||
if (table) { table.destroy(); table = null; }
|
|
||||||
|
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-4">Loading…</td></tr>';
|
|
||||||
try {
|
|
||||||
const data = await fetch('/api/attendees.php').then(r => r.json());
|
|
||||||
const list = data.attendees ?? [];
|
|
||||||
countEl.textContent = list.length + ' total';
|
|
||||||
if (list.length === 0) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-4">No attendees yet. Upload a CSV to get started.</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tbody.innerHTML = list.map(a => `
|
|
||||||
<tr>
|
|
||||||
<td>${esc(a.first_name + ' ' + a.last_name)}</td>
|
|
||||||
<td style="color:var(--text-muted);">${esc(a.email ?? '')}</td>
|
|
||||||
<td style="color:var(--text-muted);">${esc(a.mobile_number)}</td>
|
|
||||||
<td>${esc(a.workshops ?? '—')}</td>
|
|
||||||
<td style="color:var(--text-muted);">${formatWorkshopTimes(a.workshop_times)}</td>
|
|
||||||
<td style="white-space:nowrap;">
|
|
||||||
<button class="btn btn-sm btn-accent" onclick='openEditModal(${JSON.stringify(a)})'>
|
|
||||||
<i class="fa-solid fa-pen fa-fw"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`).join('');
|
|
||||||
table = new DataTable('#attendeesTable', { columnDefs: [{ orderable: false, targets: -1 }] });
|
|
||||||
} catch {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="text-danger text-center py-4">Could not load attendees.</td></tr>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(str) {
|
|
||||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatWorkshopTimes(timesStr) {
|
|
||||||
if (!timesStr) return '—';
|
|
||||||
return esc(timesStr.split(',').map(t => {
|
|
||||||
const d = new Date(t.trim().replace(' ', 'T'));
|
|
||||||
return isNaN(d.getTime()) ? t.trim() : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
}).join(', '));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function purgeAttendees() {
|
|
||||||
if (!confirm('This will permanently delete all attendees and their registrations. Are you sure?')) return;
|
|
||||||
const btn = document.getElementById('purgeBtn');
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/attendees.php', { method: 'DELETE' });
|
|
||||||
if (!res.ok) throw new Error(await res.text());
|
|
||||||
loadAttendees();
|
|
||||||
} catch (e) {
|
|
||||||
alert('Purge failed: ' + e.message);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openApiKeyModal() {
|
|
||||||
document.getElementById('apiKeyInput').value = '';
|
|
||||||
document.getElementById('apiKeyError').textContent = '';
|
|
||||||
const statusEl = document.getElementById('apiKeyStatus');
|
|
||||||
statusEl.textContent = 'Checking…';
|
|
||||||
try {
|
|
||||||
const [keyData, costData] = await Promise.all([
|
|
||||||
fetch('/api/settings.php?key=clickatell_api_key').then(r => r.json()),
|
|
||||||
fetch('/api/settings.php?key=clickatell_cost_per_sms').then(r => r.json()),
|
|
||||||
]);
|
|
||||||
statusEl.innerHTML = keyData.set
|
|
||||||
? `API key set: <code>${esc(keyData.hint)}</code> — enter a new key to replace it.`
|
|
||||||
: '<span class="text-warning">No API key configured.</span> Enter one below to enable SMS sending.';
|
|
||||||
document.getElementById('costPerSmsInput').value = costData.value ?? '0.04849';
|
|
||||||
} catch {
|
|
||||||
statusEl.textContent = 'Could not load current settings.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveApiKey() {
|
|
||||||
const key = document.getElementById('apiKeyInput').value.trim();
|
|
||||||
const cost = document.getElementById('costPerSmsInput').value.trim();
|
|
||||||
const errEl = document.getElementById('apiKeyError');
|
|
||||||
const btn = document.getElementById('apiKeySaveBtn');
|
|
||||||
errEl.textContent = '';
|
|
||||||
if (!key && !cost) { errEl.textContent = 'Please enter at least one value.'; return; }
|
|
||||||
const costNum = parseFloat(cost);
|
|
||||||
if (cost && (isNaN(costNum) || costNum < 0)) { errEl.textContent = 'Cost must be a positive number.'; return; }
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
const saves = [];
|
|
||||||
if (key) saves.push(fetch('/api/settings.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'clickatell_api_key', value: key }) }));
|
|
||||||
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 }) }));
|
|
||||||
const results = await Promise.all(saves);
|
|
||||||
for (const res of results) { if (!res.ok) throw new Error((await res.json()).error ?? 'Save failed'); }
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('apiKeyModal')).hide();
|
|
||||||
} catch (e) {
|
|
||||||
errEl.textContent = e.message;
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAttendees();
|
|
||||||
|
|
||||||
// ─── Rejected records ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let rejectedRows = [];
|
|
||||||
let rejectedFilter = 'no_mobile';
|
|
||||||
|
|
||||||
function showRejectedRecords(rejected) {
|
|
||||||
rejectedRows = rejected.map(r => ({ ...r, dismissed: false }));
|
|
||||||
rejectedFilter = 'no_mobile';
|
|
||||||
document.querySelectorAll('#rejectedFilter button').forEach(b => b.classList.remove('active'));
|
|
||||||
document.querySelector('#rejectedFilter button[data-filter="no_mobile"]').classList.add('active');
|
|
||||||
renderRejectedTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRejectedTable() {
|
|
||||||
const section = document.getElementById('rejectedSection');
|
|
||||||
const tbody = document.getElementById('rejectedBody');
|
|
||||||
const countEl = document.getElementById('rejectedCount');
|
|
||||||
|
|
||||||
const active = rejectedRows.filter(r => !r.dismissed);
|
|
||||||
if (active.length === 0) { section.classList.add('d-none'); return; }
|
|
||||||
|
|
||||||
const visible = rejectedFilter === 'all'
|
|
||||||
? active
|
|
||||||
: rejectedFilter === 'not_attending'
|
|
||||||
? active.filter(r => r.reason === 'not_attending')
|
|
||||||
: active.filter(r => r.reason !== 'not_attending');
|
|
||||||
section.classList.remove('d-none');
|
|
||||||
countEl.textContent = active.length;
|
|
||||||
|
|
||||||
if (visible.length === 0) {
|
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="text-muted text-center py-3">No records match this filter.</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = visible.map(r => {
|
|
||||||
const idx = rejectedRows.indexOf(r);
|
|
||||||
const reasonBadges = {
|
|
||||||
not_attending: '<span class="badge" style="background:rgba(250,204,21,0.15);color:#facc15;">Not Attending</span>',
|
|
||||||
no_mobile: '<span class="badge" style="background:rgba(248,113,113,0.15);color:#f87171;">No Mobile</span>',
|
|
||||||
foreign_country_code: '<span class="badge" style="background:rgba(251,146,60,0.15);color:#fb923c;">Foreign Number</span>',
|
|
||||||
invalid_number: '<span class="badge" style="background:rgba(248,113,113,0.15);color:#f87171;">Invalid Number</span>',
|
|
||||||
};
|
|
||||||
const reasonBadge = reasonBadges[r.reason] ?? reasonBadges.invalid_number;
|
|
||||||
const addBtn = r.reason !== 'not_attending'
|
|
||||||
? `<button class="btn btn-sm btn-accent me-1" onclick="addRejectedManually(${idx})"><i class="fa-solid fa-user-plus fa-fw"></i> Add</button>`
|
|
||||||
: '';
|
|
||||||
return `
|
|
||||||
<tr>
|
|
||||||
<td>${esc(r.first_name + ' ' + r.last_name)}</td>
|
|
||||||
<td style="color:var(--text-muted);">${esc(r.email ?? '')}</td>
|
|
||||||
<td style="color:var(--text-muted);">${esc(r.raw_mobile || '—')}</td>
|
|
||||||
<td style="color:var(--text-muted);">${esc(r.status || '—')}</td>
|
|
||||||
<td>${reasonBadge}</td>
|
|
||||||
<td style="white-space:nowrap;">
|
|
||||||
${addBtn}
|
|
||||||
<button class="btn btn-sm btn-secondary" onclick="dismissRejected(${idx})"><i class="fa-solid fa-xmark fa-fw"></i></button>
|
|
||||||
</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissRejected(idx) {
|
|
||||||
rejectedRows[idx].dismissed = true;
|
|
||||||
renderRejectedTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissAllRejected() {
|
|
||||||
rejectedRows.forEach(r => { r.dismissed = true; });
|
|
||||||
renderRejectedTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRejectedManually(idx) {
|
|
||||||
const r = rejectedRows[idx];
|
|
||||||
openEditModal({
|
|
||||||
id: 0,
|
|
||||||
first_name: r.first_name,
|
|
||||||
last_name: r.last_name,
|
|
||||||
email: r.email ?? '',
|
|
||||||
mobile_number: r.raw_mobile ?? '',
|
|
||||||
workshop_session_id: null,
|
|
||||||
_rejectedIdx: idx,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll('#rejectedFilter button').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
document.querySelectorAll('#rejectedFilter button').forEach(b => b.classList.remove('active'));
|
|
||||||
btn.classList.add('active');
|
|
||||||
rejectedFilter = btn.dataset.filter;
|
|
||||||
renderRejectedTable();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let editingAttendee = null;
|
|
||||||
|
|
||||||
function openEditModal(a) {
|
|
||||||
editingAttendee = a;
|
|
||||||
document.getElementById('editFirstName').value = a.first_name ?? '';
|
|
||||||
document.getElementById('editLastName').value = a.last_name ?? '';
|
|
||||||
document.getElementById('editEmail').value = a.email ?? '';
|
|
||||||
document.getElementById('editMobile').value = a.mobile_number ?? '';
|
|
||||||
document.getElementById('editError').textContent = '';
|
|
||||||
|
|
||||||
const sel = document.getElementById('editWorkshop');
|
|
||||||
sel.innerHTML = '<option value="">— No workshop —</option>';
|
|
||||||
fetch('/api/workshops.php').then(r => r.json()).then(data => {
|
|
||||||
const workshops = data.workshops ?? [];
|
|
||||||
// Group sessions by workshop name (case-insensitive) so capitalisation
|
|
||||||
// variants (e.g. "Hot Sauce tasting" vs "Hot Sauce Tasting") are merged.
|
|
||||||
const groups = {};
|
|
||||||
workshops.forEach(ws => {
|
|
||||||
const key = ws.workshop_name.toLowerCase();
|
|
||||||
if (!groups[key]) groups[key] = { label: ws.workshop_name, sessions: [] };
|
|
||||||
groups[key].sessions.push(ws);
|
|
||||||
});
|
|
||||||
Object.values(groups).forEach(({ label, sessions }) => {
|
|
||||||
const fmtTime = ws => ws.workshop_time
|
|
||||||
? new Date(ws.workshop_time.replace(' ', 'T')).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
||||||
: null;
|
|
||||||
if (sessions.length === 1) {
|
|
||||||
const ws = sessions[0];
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = ws.id;
|
|
||||||
const time = fmtTime(ws);
|
|
||||||
opt.textContent = label + (time ? ' (' + time + ')' : '');
|
|
||||||
if (String(ws.id) === String(a.workshop_session_id)) opt.selected = true;
|
|
||||||
sel.appendChild(opt);
|
|
||||||
} else {
|
|
||||||
const grp = document.createElement('optgroup');
|
|
||||||
grp.label = label;
|
|
||||||
sessions.forEach(ws => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = ws.id;
|
|
||||||
opt.textContent = fmtTime(ws) ?? '(no time)';
|
|
||||||
if (String(ws.id) === String(a.workshop_session_id)) opt.selected = true;
|
|
||||||
grp.appendChild(opt);
|
|
||||||
});
|
|
||||||
sel.appendChild(grp);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
new bootstrap.Modal(document.getElementById('editModal')).show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEdit() {
|
|
||||||
const errEl = document.getElementById('editError');
|
|
||||||
const btn = document.getElementById('editSaveBtn');
|
|
||||||
errEl.textContent = '';
|
|
||||||
btn.disabled = true;
|
|
||||||
try {
|
|
||||||
const wsVal = document.getElementById('editWorkshop').value;
|
|
||||||
const res = await fetch('/api/attendees.php', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
id: editingAttendee.id,
|
|
||||||
first_name: document.getElementById('editFirstName').value.trim(),
|
|
||||||
last_name: document.getElementById('editLastName').value.trim(),
|
|
||||||
email: document.getElementById('editEmail').value.trim(),
|
|
||||||
mobile_number: document.getElementById('editMobile').value.trim(),
|
|
||||||
workshop_session_id: wsVal ? parseInt(wsVal, 10) : 0,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error ?? 'Save failed');
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
|
|
||||||
// If this was a manual-add from the rejected table, dismiss that row
|
|
||||||
if (editingAttendee._rejectedIdx !== undefined) {
|
|
||||||
rejectedRows[editingAttendee._rejectedIdx].dismissed = true;
|
|
||||||
renderRejectedTable();
|
|
||||||
}
|
|
||||||
loadAttendees();
|
|
||||||
} catch (e) {
|
|
||||||
errEl.textContent = e.message;
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showDuplicateModal(duplicates) {
|
|
||||||
const container = document.getElementById('dupGroups');
|
|
||||||
container.innerHTML = duplicates.map((dup, gi) => {
|
|
||||||
const rowHtml = dup.rows.map((row, ri) => `
|
|
||||||
<div class="form-check mb-1">
|
|
||||||
<input class="form-check-input" type="radio" name="dup_${gi}" id="dup_${gi}_${ri}" value="${ri}"${ri === 0 ? ' checked' : ''}>
|
|
||||||
<label class="form-check-label" for="dup_${gi}_${ri}">
|
|
||||||
<strong>${esc(row.first_name)} ${esc(row.last_name)}</strong>
|
|
||||||
${row.email ? `<span class="text-muted ms-1">· ${esc(row.email)}</span>` : ''}
|
|
||||||
${row.workshop_name ? `<span class="badge bg-secondary ms-1">${esc(row.workshop_name)}</span>` : ''}
|
|
||||||
</label>
|
|
||||||
</div>`).join('');
|
|
||||||
return `
|
|
||||||
<div class="mb-3 p-3 rounded" style="background:var(--bg-card);border:1px solid var(--border);">
|
|
||||||
<div class="mb-2 fw-semibold" style="font-size:0.85rem;color:var(--text-muted);">
|
|
||||||
<i class="fa-solid fa-phone fa-fw me-1 text-warning"></i>${esc(dup.mobile)}
|
|
||||||
</div>
|
|
||||||
${rowHtml}
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
new bootstrap.Modal(document.getElementById('dupModal')).show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelDuplicates() {
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('dupModal')).hide();
|
|
||||||
if (pendingFile) { dz.removeFile(pendingFile); pendingFile = null; }
|
|
||||||
currentDuplicates = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmDuplicates() {
|
|
||||||
const resolutions = {};
|
|
||||||
currentDuplicates.forEach((dup, gi) => {
|
|
||||||
const sel = document.querySelector(`input[name="dup_${gi}"]:checked`);
|
|
||||||
if (sel) resolutions[dup.mobile] = parseInt(sel.value, 10);
|
|
||||||
});
|
|
||||||
const btn = document.getElementById('dupConfirmBtn');
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin fa-fw"></i> Importing…';
|
|
||||||
try {
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', pendingFile, pendingFile.name);
|
|
||||||
fd.append('mode', document.querySelector('input[name="importMode"]:checked').value);
|
|
||||||
fd.append('resolutions', JSON.stringify(resolutions));
|
|
||||||
const res = await fetch('/api/attendees.php', { method: 'POST', body: fd });
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error ?? 'Import failed');
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('dupModal')).hide();
|
|
||||||
dz.removeFile(pendingFile);
|
|
||||||
pendingFile = null;
|
|
||||||
currentDuplicates = [];
|
|
||||||
const resultEl = document.getElementById('importResult');
|
|
||||||
const parts = [];
|
|
||||||
if (data.imported) parts.push(`<span class="text-success">${data.imported} added</span>`);
|
|
||||||
if (data.updated) parts.push(`<span class="text-info">${data.updated} updated</span>`);
|
|
||||||
if (data.skipped) {
|
|
||||||
const reasons = [];
|
|
||||||
if (data.skip_reasons?.not_attending) reasons.push(`${data.skip_reasons.not_attending} not attending`);
|
|
||||||
if (data.skip_reasons?.no_mobile) reasons.push(`${data.skip_reasons.no_mobile} invalid/missing mobile`);
|
|
||||||
const detail = reasons.length ? ` (${reasons.join(', ')})` : '';
|
|
||||||
parts.push(`<span class="text-muted">${data.skipped} skipped${detail}</span>`);
|
|
||||||
}
|
|
||||||
resultEl.innerHTML = parts.length ? 'Import complete: ' + parts.join(', ') + '.' : 'Import complete.';
|
|
||||||
if (data.rejected && data.rejected.length > 0) {
|
|
||||||
showRejectedRecords(data.rejected);
|
|
||||||
} else {
|
|
||||||
rejectedRows = [];
|
|
||||||
renderRejectedTable();
|
|
||||||
}
|
|
||||||
loadAttendees();
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('importResult').innerHTML = `<span class="text-danger">Import failed: ${esc(e.message)}</span>`;
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = '<i class="fa-solid fa-check fa-fw"></i> Confirm Import';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- ─── Edit attendee modal ─── -->
|
<!-- ─── Edit attendee modal ─── -->
|
||||||
<div class="modal fade" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" aria-hidden="true">
|
<div class="modal fade" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" aria-hidden="true">
|
||||||
@@ -748,23 +273,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
async function checkAuth() {
|
<nav class="bottom-nav" aria-label="Navigation">
|
||||||
try {
|
<a href="index.html" class="bottom-nav-item">
|
||||||
const r = await fetch('/api/admin.php', { redirect: 'manual' });
|
<i class="fa-solid fa-house"></i>
|
||||||
if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }
|
<span>Home</span>
|
||||||
const d = await r.json();
|
</a>
|
||||||
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
|
<a href="attendees.html" class="bottom-nav-item active">
|
||||||
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));
|
<i class="fa-solid fa-users"></i>
|
||||||
const name = d.name || d.username;
|
<span>Attendees</span>
|
||||||
if (name) {
|
</a>
|
||||||
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
|
<a href="templates.html" class="bottom-nav-item">
|
||||||
}
|
<i class="fa-solid fa-comment-sms"></i>
|
||||||
} catch { }
|
<span>Templates</span>
|
||||||
}
|
</a>
|
||||||
checkAuth();
|
<a href="schedule.html" class="bottom-nav-item">
|
||||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
|
<i class="fa-solid fa-calendar-days"></i>
|
||||||
</script>
|
<span>Schedule</span>
|
||||||
|
</a>
|
||||||
|
<a href="delivery-log.html" class="bottom-nav-item">
|
||||||
|
<i class="fa-solid fa-list-check"></i>
|
||||||
|
<span>Log</span>
|
||||||
|
</a>
|
||||||
|
<a href="admin.html" class="bottom-nav-item admin-nav-link d-none">
|
||||||
|
<i class="fa-solid fa-user-shield"></i>
|
||||||
|
<span>Admin</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+34
-142
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>Delivery Log — IMFestival SMS Dashboard</title>
|
<title>Delivery Log — IMFestival SMS Dashboard</title>
|
||||||
|
|
||||||
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||||
@@ -17,45 +17,8 @@
|
|||||||
|
|
||||||
<!-- ─── Mobile top bar ─── -->
|
<!-- ─── Mobile top bar ─── -->
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
|
||||||
<i class="fa-solid fa-bars"></i>
|
|
||||||
</button>
|
|
||||||
<span style="font-weight:600;font-size:0.95rem;">Delivery Log</span>
|
<span style="font-weight:600;font-size:0.95rem;">Delivery Log</span>
|
||||||
</div>
|
<span class="nav-username ms-auto" style="font-size:0.82rem;color:var(--text-muted);">User</span>
|
||||||
|
|
||||||
<!-- ─── Mobile offcanvas sidebar ─── -->
|
|
||||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
|
||||||
<div class="offcanvas-header">
|
|
||||||
<div class="brand"
|
|
||||||
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
|
||||||
<span>IMFestival SMS Dashboard</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="offcanvas-body p-3">
|
|
||||||
<nav class="d-flex flex-column gap-1">
|
|
||||||
<a href="index.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-house fa-fw"></i>
|
|
||||||
Home
|
|
||||||
</a>
|
|
||||||
<a href="attendees.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-users fa-fw"></i>
|
|
||||||
Attendees
|
|
||||||
</a>
|
|
||||||
<a href="templates.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
|
||||||
SMS Templates
|
|
||||||
</a>
|
|
||||||
<a href="schedule.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
|
||||||
Schedule
|
|
||||||
</a>
|
|
||||||
<a href="delivery-log.html" class="nav-link-custom active">
|
|
||||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
|
||||||
Delivery Log
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── Desktop sidebar ─── -->
|
<!-- ─── Desktop sidebar ─── -->
|
||||||
@@ -111,21 +74,21 @@
|
|||||||
|
|
||||||
<!-- Summary stats -->
|
<!-- Summary stats -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-6 col-sm-4">
|
<div class="col-4">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Sent</div>
|
<div class="stat-label">Sent</div>
|
||||||
<div class="stat-value" id="stat-sent" style="color:#4ade80;">—</div>
|
<div class="stat-value" id="stat-sent" style="color:#4ade80;">—</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-sm-4">
|
<div class="col-4">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Failed</div>
|
<div class="stat-label">Failed</div>
|
||||||
<div class="stat-value" id="stat-failed" style="color:#f87171;">—</div>
|
<div class="stat-value" id="stat-failed" style="color:#f87171;">—</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6 col-sm-4">
|
<div class="col-4">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Success Rate</div>
|
<div class="stat-label">S/R</div>
|
||||||
<div class="stat-value" id="stat-rate">—</div>
|
<div class="stat-value" id="stat-rate">—</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,110 +120,39 @@
|
|||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
|
<script src="assets/js/nav.js"></script>
|
||||||
|
<script src="assets/js/app.js"></script>
|
||||||
<script src="https://code.jquery.com/jquery-4.0.0.min.js"
|
<script src="https://code.jquery.com/jquery-4.0.0.min.js"
|
||||||
integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
|
integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
|
||||||
<script src="https://cdn.datatables.net/2.3.8/js/dataTables.min.js"></script>
|
<script src="https://cdn.datatables.net/2.3.8/js/dataTables.min.js"></script>
|
||||||
<script>
|
|
||||||
let logTable = null;
|
|
||||||
|
|
||||||
function esc(str) {
|
|
||||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorDesc(msg) {
|
<nav class="bottom-nav" aria-label="Navigation">
|
||||||
if (!msg) return '';
|
<a href="index.html" class="bottom-nav-item">
|
||||||
try {
|
<i class="fa-solid fa-house"></i>
|
||||||
const parsed = JSON.parse(msg);
|
<span>Home</span>
|
||||||
// Top-level error (older Clickatell responses)
|
</a>
|
||||||
if (parsed.error) return parsed.error;
|
<a href="attendees.html" class="bottom-nav-item">
|
||||||
// Clickatell v2: { messages: [{ error, errorDescription }] }
|
<i class="fa-solid fa-users"></i>
|
||||||
const m = Array.isArray(parsed.messages) ? parsed.messages[0] : null;
|
<span>Attendees</span>
|
||||||
if (m?.error) return m.error;
|
</a>
|
||||||
if (m?.errorDescription) return m.errorDescription;
|
<a href="templates.html" class="bottom-nav-item">
|
||||||
return parsed.message || msg;
|
<i class="fa-solid fa-comment-sms"></i>
|
||||||
} catch {
|
<span>Templates</span>
|
||||||
return msg;
|
</a>
|
||||||
}
|
<a href="schedule.html" class="bottom-nav-item">
|
||||||
}
|
<i class="fa-solid fa-calendar-days"></i>
|
||||||
|
<span>Schedule</span>
|
||||||
function fmtDate(str) {
|
</a>
|
||||||
if (!str) return '—';
|
<a href="delivery-log.html" class="bottom-nav-item active">
|
||||||
return new Date(str).toLocaleString('en-GB', {
|
<i class="fa-solid fa-list-check"></i>
|
||||||
day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'
|
<span>Log</span>
|
||||||
});
|
</a>
|
||||||
}
|
<a href="admin.html" class="bottom-nav-item admin-nav-link d-none">
|
||||||
|
<i class="fa-solid fa-user-shield"></i>
|
||||||
async function loadLog() {
|
<span>Admin</span>
|
||||||
const tbody = document.getElementById('logBody');
|
</a>
|
||||||
|
</nav>
|
||||||
try {
|
|
||||||
const data = await fetch('/api/delivery-log.php?all=1').then(r => r.json());
|
|
||||||
const stats = data.stats ?? {};
|
|
||||||
const entries = data.entries ?? [];
|
|
||||||
|
|
||||||
document.getElementById('stat-sent').textContent = stats.total_sent ?? '—';
|
|
||||||
document.getElementById('stat-failed').textContent = stats.total_failed ?? '—';
|
|
||||||
document.getElementById('stat-rate').textContent = stats.success_rate != null ? stats.success_rate + '%' : '—';
|
|
||||||
|
|
||||||
// Destroy existing DataTable before rewriting DOM
|
|
||||||
if (logTable) { logTable.destroy(); logTable = null; }
|
|
||||||
|
|
||||||
if (entries.length === 0) {
|
|
||||||
tbody.innerHTML = `<tr><td colspan="5" class="text-muted text-center py-4">No messages sent yet.</td></tr>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody.innerHTML = entries.map(e => {
|
|
||||||
const statusBadge = e.status === 'failed'
|
|
||||||
? `<span class="badge-failed" style="cursor:help;"${e.error_message ? ` data-bs-toggle="tooltip" data-bs-placement="left" data-bs-title="${esc(errorDesc(e.error_message))}"` : ''}>Failed</span>`
|
|
||||||
: `<span class="badge-delivered">Sent</span>`;
|
|
||||||
return `
|
|
||||||
<tr>
|
|
||||||
<td>${esc(e.attendee_name ?? '—')}</td>
|
|
||||||
<td style="color:var(--text-muted);">${e.message_body
|
|
||||||
? `<span style="cursor:help;border-bottom:1px dashed var(--text-muted);" data-bs-toggle="tooltip" data-bs-placement="top" data-bs-title="${esc(e.message_body)}">${esc(e.template_name)}</span>`
|
|
||||||
: esc(e.template_name)}</td>
|
|
||||||
<td style="color:var(--text-muted);">${esc(e.mobile_number)}</td>
|
|
||||||
<td>${statusBadge}</td>
|
|
||||||
<td style="color:var(--text-muted);font-size:0.82rem;" data-order="${e.sent_at ?? ''}">${fmtDate(e.sent_at)}</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
logTable = new DataTable('#logTable', {
|
|
||||||
order: [[4, 'desc']],
|
|
||||||
columnDefs: [{ orderable: false, targets: 3 }],
|
|
||||||
pageLength: 25,
|
|
||||||
drawCallback: () => {
|
|
||||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
|
||||||
bootstrap.Tooltip.getOrCreateInstance(el);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
tbody.innerHTML = `<tr><td colspan="5" class="text-danger text-center py-4">Could not load delivery log.</td></tr>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadLog();
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
async function checkAuth() {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/admin.php', { redirect: 'manual' });
|
|
||||||
if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }
|
|
||||||
const d = await r.json();
|
|
||||||
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
|
|
||||||
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));
|
|
||||||
const name = d.name || d.username;
|
|
||||||
if (name) {
|
|
||||||
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
checkAuth();
|
|
||||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+37
-131
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>Home — IMFestival SMS Dashboard</title>
|
<title>Home — IMFestival SMS Dashboard</title>
|
||||||
|
|
||||||
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||||
@@ -16,45 +16,8 @@
|
|||||||
|
|
||||||
<!-- ─── Mobile top bar ─── -->
|
<!-- ─── Mobile top bar ─── -->
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
|
||||||
<i class="fa-solid fa-bars"></i>
|
|
||||||
</button>
|
|
||||||
<span style="font-weight:600;font-size:0.95rem;">SMS Dashboard</span>
|
<span style="font-weight:600;font-size:0.95rem;">SMS Dashboard</span>
|
||||||
</div>
|
<span class="nav-username ms-auto" style="font-size:0.82rem;color:var(--text-muted);">User</span>
|
||||||
|
|
||||||
<!-- ─── Mobile offcanvas sidebar ─── -->
|
|
||||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
|
||||||
<div class="offcanvas-header">
|
|
||||||
<div class="brand"
|
|
||||||
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
|
||||||
<span>IMFestival SMS Dashboard</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="offcanvas-body p-3">
|
|
||||||
<nav class="d-flex flex-column gap-1">
|
|
||||||
<a href="index.html" class="nav-link-custom active">
|
|
||||||
<i class="fa-solid fa-house fa-fw"></i>
|
|
||||||
Home
|
|
||||||
</a>
|
|
||||||
<a href="attendees.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-users fa-fw"></i>
|
|
||||||
Attendees
|
|
||||||
</a>
|
|
||||||
<a href="templates.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
|
||||||
SMS Templates
|
|
||||||
</a>
|
|
||||||
<a href="schedule.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
|
||||||
Schedule
|
|
||||||
</a>
|
|
||||||
<a href="delivery-log.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
|
||||||
Delivery Log
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── Desktop sidebar ─── -->
|
<!-- ─── Desktop sidebar ─── -->
|
||||||
@@ -110,28 +73,28 @@
|
|||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="row g-3 mb-4">
|
<div class="row g-3 mb-4">
|
||||||
<div class="col-12 col-sm-3">
|
<div class="col-6 col-sm-3">
|
||||||
<div class="stat-card border-0">
|
<div class="stat-card border-0">
|
||||||
<div class="stat-label">Attendees</div>
|
<div class="stat-label">Attendees</div>
|
||||||
<div class="stat-value" id="stat-attendees">—</div>
|
<div class="stat-value" id="stat-attendees">—</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-3">
|
<div class="col-6 col-sm-3">
|
||||||
<div class="stat-card border-0">
|
<div class="stat-card border-0">
|
||||||
<div class="stat-label">Total Sent</div>
|
<div class="stat-label">Total Sent</div>
|
||||||
<div class="stat-value" id="stat-sent">—</div>
|
<div class="stat-value" id="stat-sent">—</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-3">
|
<div class="col-6 col-sm-3">
|
||||||
<div class="stat-card border-0">
|
<div class="stat-card border-0">
|
||||||
<div class="stat-label">Success Rate</div>
|
<div class="stat-label">Success Rate</div>
|
||||||
<div class="stat-value" id="stat-rate">—</div>
|
<div class="stat-value" id="stat-rate">—</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-sm-3">
|
<div class="col-6 col-sm-3">
|
||||||
<div class="stat-card border-0">
|
<div class="stat-card border-0" id="spendCard" role="button" tabindex="0" style="cursor:pointer;">
|
||||||
<div class="stat-label">Estimated Cost</div>
|
<div class="stat-label" id="stat-spend-label">Balance</div>
|
||||||
<div class="stat-value" id="stat-cost">—</div>
|
<div class="stat-value" id="stat-spend">—</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -267,93 +230,36 @@
|
|||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
<script>
|
<script src="assets/js/nav.js"></script>
|
||||||
function esc(str) {
|
<script src="assets/js/app.js"></script>
|
||||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openApiKeyModal() {
|
|
||||||
document.getElementById('apiKeyInput').value = '';
|
|
||||||
document.getElementById('apiKeyError').textContent = '';
|
|
||||||
const statusEl = document.getElementById('apiKeyStatus');
|
|
||||||
statusEl.textContent = 'Checking…';
|
|
||||||
try {
|
|
||||||
const [keyData, costData] = await Promise.all([
|
|
||||||
fetch('/api/settings.php?key=clickatell_api_key').then(r => r.json()),
|
|
||||||
fetch('/api/settings.php?key=clickatell_cost_per_sms').then(r => r.json()),
|
|
||||||
]);
|
|
||||||
statusEl.innerHTML = keyData.set
|
|
||||||
? `API key set: <code>${esc(keyData.hint)}</code> — enter a new key to replace it.`
|
|
||||||
: '<span class="text-warning">No API key configured.</span> Enter one below to enable SMS sending.';
|
|
||||||
document.getElementById('costPerSmsInput').value = costData.value ?? '0.04849';
|
|
||||||
} catch {
|
|
||||||
statusEl.textContent = 'Could not load current settings.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveApiKey() {
|
<nav class="bottom-nav" aria-label="Navigation">
|
||||||
const key = document.getElementById('apiKeyInput').value.trim();
|
<a href="index.html" class="bottom-nav-item active">
|
||||||
const cost = document.getElementById('costPerSmsInput').value.trim();
|
<i class="fa-solid fa-house"></i>
|
||||||
const errEl = document.getElementById('apiKeyError');
|
<span>Home</span>
|
||||||
const btn = document.getElementById('apiKeySaveBtn');
|
</a>
|
||||||
errEl.textContent = '';
|
<a href="attendees.html" class="bottom-nav-item">
|
||||||
if (!key && !cost) { errEl.textContent = 'Please enter at least one value.'; return; }
|
<i class="fa-solid fa-users"></i>
|
||||||
const costNum = parseFloat(cost);
|
<span>Attendees</span>
|
||||||
if (cost && (isNaN(costNum) || costNum < 0)) { errEl.textContent = 'Cost must be a positive number.'; return; }
|
</a>
|
||||||
btn.disabled = true;
|
<a href="templates.html" class="bottom-nav-item">
|
||||||
try {
|
<i class="fa-solid fa-comment-sms"></i>
|
||||||
const saves = [];
|
<span>Templates</span>
|
||||||
if (key) saves.push(fetch('/api/settings.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: 'clickatell_api_key', value: key }) }));
|
</a>
|
||||||
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 }) }));
|
<a href="schedule.html" class="bottom-nav-item">
|
||||||
const results = await Promise.all(saves);
|
<i class="fa-solid fa-calendar-days"></i>
|
||||||
for (const res of results) { if (!res.ok) throw new Error((await res.json()).error ?? 'Save failed'); }
|
<span>Schedule</span>
|
||||||
bootstrap.Modal.getInstance(document.getElementById('apiKeyModal')).hide();
|
</a>
|
||||||
loadStats();
|
<a href="delivery-log.html" class="bottom-nav-item">
|
||||||
} catch (e) {
|
<i class="fa-solid fa-list-check"></i>
|
||||||
errEl.textContent = e.message;
|
<span>Log</span>
|
||||||
} finally {
|
</a>
|
||||||
btn.disabled = false;
|
<a href="admin.html" class="bottom-nav-item admin-nav-link d-none">
|
||||||
}
|
<i class="fa-solid fa-user-shield"></i>
|
||||||
}
|
<span>Admin</span>
|
||||||
|
</a>
|
||||||
async function loadStats() {
|
</nav>
|
||||||
try {
|
|
||||||
const [att, log] = await Promise.all([
|
|
||||||
fetch('/api/attendees.php').then(r => r.json()),
|
|
||||||
fetch('/api/delivery-log.php').then(r => r.json()),
|
|
||||||
]);
|
|
||||||
document.getElementById('stat-attendees').textContent = att.total ?? '—';
|
|
||||||
document.getElementById('stat-sent').textContent = log.stats?.total_sent ?? '—';
|
|
||||||
const rate = log.stats?.success_rate;
|
|
||||||
document.getElementById('stat-rate').textContent = rate != null ? rate + '%' : '—';
|
|
||||||
const sent = log.stats?.total_sent;
|
|
||||||
const costPerSms = parseFloat((await fetch('/api/settings.php?key=clickatell_cost_per_sms').then(r => r.json())).value ?? '0.04849');
|
|
||||||
document.getElementById('stat-cost').textContent = sent != null
|
|
||||||
? '£' + (sent * costPerSms).toFixed(2)
|
|
||||||
: '—';
|
|
||||||
} catch { /* DB unavailable — stats stay as dashes */ }
|
|
||||||
}
|
|
||||||
loadStats();
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
async function checkAuth() {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/admin.php', { redirect: 'manual' });
|
|
||||||
if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }
|
|
||||||
const d = await r.json();
|
|
||||||
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
|
|
||||||
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));
|
|
||||||
const adminSection = document.getElementById('adminSection');
|
|
||||||
if (adminSection) adminSection.classList.toggle('d-none', !d.is_admin);
|
|
||||||
const name = d.name || d.username;
|
|
||||||
if (name) {
|
|
||||||
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
checkAuth();
|
|
||||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+36
-310
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>Schedule — IMFestival SMS Dashboard</title>
|
<title>Schedule — IMFestival SMS Dashboard</title>
|
||||||
|
|
||||||
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||||
@@ -31,45 +31,8 @@
|
|||||||
|
|
||||||
<!-- ─── Mobile top bar ─── -->
|
<!-- ─── Mobile top bar ─── -->
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
|
||||||
<i class="fa-solid fa-bars"></i>
|
|
||||||
</button>
|
|
||||||
<span style="font-weight:600;font-size:0.95rem;">Schedule</span>
|
<span style="font-weight:600;font-size:0.95rem;">Schedule</span>
|
||||||
</div>
|
<span class="nav-username ms-auto" style="font-size:0.82rem;color:var(--text-muted);">User</span>
|
||||||
|
|
||||||
<!-- ─── Mobile offcanvas sidebar ─── -->
|
|
||||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
|
||||||
<div class="offcanvas-header">
|
|
||||||
<div class="brand"
|
|
||||||
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
|
||||||
<span>IMFestival SMS Dashboard</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="offcanvas-body p-3">
|
|
||||||
<nav class="d-flex flex-column gap-1">
|
|
||||||
<a href="index.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-house fa-fw"></i>
|
|
||||||
Home
|
|
||||||
</a>
|
|
||||||
<a href="attendees.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-users fa-fw"></i>
|
|
||||||
Attendees
|
|
||||||
</a>
|
|
||||||
<a href="templates.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
|
||||||
SMS Templates
|
|
||||||
</a>
|
|
||||||
<a href="schedule.html" class="nav-link-custom active">
|
|
||||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
|
||||||
Schedule
|
|
||||||
</a>
|
|
||||||
<a href="delivery-log.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
|
||||||
Delivery Log
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── Desktop sidebar ─── -->
|
<!-- ─── Desktop sidebar ─── -->
|
||||||
@@ -117,6 +80,11 @@
|
|||||||
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded from
|
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded from
|
||||||
cost calculations.
|
cost calculations.
|
||||||
</div>
|
</div>
|
||||||
|
<div id="noApiKeyBanner" class="alert alert-danger"
|
||||||
|
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
|
||||||
|
<i class="fa-solid fa-circle-exclamation me-1"></i> No Clickatell API key configured —
|
||||||
|
<a href="index.html" class="alert-link">set one in Admin settings</a> before scheduling.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>Schedule</h1>
|
<h1>Schedule</h1>
|
||||||
@@ -161,7 +129,7 @@
|
|||||||
<option value="" disabled selected>Loading templates…</option>
|
<option value="" disabled selected>Loading templates…</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button class="btn btn-primary" onclick="addSchedule()">
|
<button class="btn btn-primary" id="addScheduleBtn" onclick="addSchedule()">
|
||||||
<i class="fa-solid fa-plus me-1"></i> Add to Schedule
|
<i class="fa-solid fa-plus me-1"></i> Add to Schedule
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,278 +213,36 @@
|
|||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
<script>
|
<script src="assets/js/nav.js"></script>
|
||||||
// ── Mode toggle ──────────────────────────────────────────
|
<script src="assets/js/app.js"></script>
|
||||||
let currentMode = 'specific';
|
|
||||||
|
|
||||||
function setMode(mode) {
|
|
||||||
currentMode = mode;
|
|
||||||
document.getElementById('relativeFields').style.display = mode === 'relative' ? '' : 'none';
|
|
||||||
document.getElementById('specificFields').style.display = mode === 'specific' ? '' : 'none';
|
|
||||||
document.getElementById('modeRelative').classList.toggle('active-mode', mode === 'relative');
|
|
||||||
document.getElementById('modeSpecific').classList.toggle('active-mode', mode === 'specific');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Load templates into select ───────────────────────────
|
<nav class="bottom-nav" aria-label="Navigation">
|
||||||
async function loadTemplateOptions() {
|
<a href="index.html" class="bottom-nav-item">
|
||||||
const sel = document.getElementById('templateSelect');
|
<i class="fa-solid fa-house"></i>
|
||||||
try {
|
<span>Home</span>
|
||||||
const data = await fetch('/api/templates.php').then(r => r.json());
|
</a>
|
||||||
const templates = data.templates ?? [];
|
<a href="attendees.html" class="bottom-nav-item">
|
||||||
if (templates.length === 0) {
|
<i class="fa-solid fa-users"></i>
|
||||||
sel.innerHTML = '<option value="" disabled selected>No templates — create one first</option>';
|
<span>Attendees</span>
|
||||||
return;
|
</a>
|
||||||
}
|
<a href="templates.html" class="bottom-nav-item">
|
||||||
sel.innerHTML = templates.map(t =>
|
<i class="fa-solid fa-comment-sms"></i>
|
||||||
`<option value="${t.id}">${esc(t.name)}</option>`
|
<span>Templates</span>
|
||||||
).join('');
|
</a>
|
||||||
} catch {
|
<a href="schedule.html" class="bottom-nav-item active">
|
||||||
sel.innerHTML = '<option value="" disabled selected>Could not load templates</option>';
|
<i class="fa-solid fa-calendar-days"></i>
|
||||||
}
|
<span>Schedule</span>
|
||||||
}
|
</a>
|
||||||
|
<a href="delivery-log.html" class="bottom-nav-item">
|
||||||
// ── Add a rule ───────────────────────────────────────────
|
<i class="fa-solid fa-list-check"></i>
|
||||||
async function addSchedule() {
|
<span>Log</span>
|
||||||
const templateId = document.getElementById('templateSelect').value;
|
</a>
|
||||||
if (!templateId) { alert('Please select a template.'); return; }
|
<a href="admin.html" class="bottom-nav-item admin-nav-link d-none">
|
||||||
|
<i class="fa-solid fa-user-shield"></i>
|
||||||
const payload = { template_id: parseInt(templateId), mode: currentMode };
|
<span>Admin</span>
|
||||||
|
</a>
|
||||||
if (currentMode === 'relative') {
|
</nav>
|
||||||
payload.offset_value = parseInt(document.getElementById('offsetValue').value) || 1;
|
|
||||||
payload.offset_unit = document.getElementById('offsetUnit').value;
|
|
||||||
} else {
|
|
||||||
const dt = document.getElementById('specificDatetime').value;
|
|
||||||
if (!dt) { alert('Please pick a date and time.'); return; }
|
|
||||||
payload.specific_datetime = dt;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch('/api/schedule.php', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json();
|
|
||||||
alert('Error: ' + (err.error ?? 'Unknown error'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
loadSchedule();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Edit modal ───────────────────────────────────────────
|
|
||||||
let editMode = 'specific';
|
|
||||||
|
|
||||||
function setEditMode(mode) {
|
|
||||||
editMode = mode;
|
|
||||||
document.getElementById('editRelativeFields').style.display = mode === 'relative' ? '' : 'none';
|
|
||||||
document.getElementById('editSpecificFields').style.display = mode === 'specific' ? '' : 'none';
|
|
||||||
document.getElementById('editModeRelative').classList.toggle('active-mode', mode === 'relative');
|
|
||||||
document.getElementById('editModeSpecific').classList.toggle('active-mode', mode === 'specific');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openEditModal(id) {
|
|
||||||
const rule = window._scheduleRules?.[id];
|
|
||||||
if (!rule) return;
|
|
||||||
document.getElementById('editId').value = rule.id;
|
|
||||||
setEditMode(rule.mode);
|
|
||||||
|
|
||||||
if (rule.mode === 'specific') {
|
|
||||||
// Convert stored datetime to datetime-local format (YYYY-MM-DDTHH:MM)
|
|
||||||
const dt = new Date(rule.specific_datetime.replace(' ', 'T'));
|
|
||||||
const pad = n => String(n).padStart(2, '0');
|
|
||||||
document.getElementById('editSpecificDatetime').value =
|
|
||||||
`${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}T${pad(dt.getHours())}:${pad(dt.getMinutes())}`;
|
|
||||||
} else {
|
|
||||||
document.getElementById('editOffsetValue').value = rule.offset_value;
|
|
||||||
document.getElementById('editOffsetUnit').value = rule.offset_unit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate template select, mirroring the main select
|
|
||||||
const mainSel = document.getElementById('templateSelect');
|
|
||||||
const editSel = document.getElementById('editTemplateSelect');
|
|
||||||
editSel.innerHTML = mainSel.innerHTML;
|
|
||||||
editSel.value = rule.template_id;
|
|
||||||
|
|
||||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('editModal')).show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEdit() {
|
|
||||||
const id = parseInt(document.getElementById('editId').value);
|
|
||||||
const templateId = parseInt(document.getElementById('editTemplateSelect').value);
|
|
||||||
if (!templateId) { alert('Please select a template.'); return; }
|
|
||||||
|
|
||||||
const payload = { template_id: templateId, mode: editMode };
|
|
||||||
|
|
||||||
if (editMode === 'relative') {
|
|
||||||
payload.offset_value = parseInt(document.getElementById('editOffsetValue').value) || 1;
|
|
||||||
payload.offset_unit = document.getElementById('editOffsetUnit').value;
|
|
||||||
} else {
|
|
||||||
const dt = document.getElementById('editSpecificDatetime').value;
|
|
||||||
if (!dt) { alert('Please pick a date and time.'); return; }
|
|
||||||
payload.specific_datetime = dt;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`/api/schedule.php?id=${id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json();
|
|
||||||
alert('Error: ' + (err.error ?? 'Unknown error'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
|
|
||||||
loadSchedule();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Remove a rule ────────────────────────────────────────
|
|
||||||
async function removeSchedule(id) {
|
|
||||||
if (!confirm('Remove this scheduled rule?')) return;
|
|
||||||
const res = await fetch(`/api/schedule.php?id=${id}`, { method: 'DELETE' });
|
|
||||||
if (!res.ok) { alert('Could not remove rule.'); return; }
|
|
||||||
loadSchedule();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Rendering ────────────────────────────────────────────
|
|
||||||
function relativeTime(date) {
|
|
||||||
const diffMs = date - Date.now();
|
|
||||||
const diffMin = Math.round(diffMs / 60000);
|
|
||||||
if (diffMin < 1) return 'now';
|
|
||||||
if (diffMin < 60) return `in ${diffMin} minute${diffMin !== 1 ? 's' : ''}`;
|
|
||||||
const diffHr = Math.round(diffMin / 60);
|
|
||||||
if (diffHr < 24) return `in ${diffHr} hour${diffHr !== 1 ? 's' : ''}`;
|
|
||||||
const diffDay = Math.round(diffHr / 24);
|
|
||||||
return `in ${diffDay} day${diffDay !== 1 ? 's' : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(date) {
|
|
||||||
return date.toLocaleString('en-GB', {
|
|
||||||
day: 'numeric', month: 'short', year: 'numeric',
|
|
||||||
hour: '2-digit', minute: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function ruleLabel(rule) {
|
|
||||||
if (rule.mode === 'specific') {
|
|
||||||
return 'Specific time: ' + formatDateTime(new Date(rule.specific_datetime));
|
|
||||||
}
|
|
||||||
const unit = { minutes: 'minute', hours: 'hour', days: 'day' }[rule.offset_unit] ?? rule.offset_unit;
|
|
||||||
const val = rule.offset_value;
|
|
||||||
return `${val} ${unit}${val !== 1 ? 's' : ''} before workshop`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(str) {
|
|
||||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSchedule() {
|
|
||||||
const list = document.getElementById('scheduleList');
|
|
||||||
const countEl = document.getElementById('scheduleCount');
|
|
||||||
const pastList = document.getElementById('pastList');
|
|
||||||
const pastCard = document.getElementById('pastCard');
|
|
||||||
const pastCountEl = document.getElementById('pastCount');
|
|
||||||
try {
|
|
||||||
const data = await fetch('/api/schedule.php').then(r => r.json());
|
|
||||||
const rules = data.rules ?? [];
|
|
||||||
|
|
||||||
// Store rules by id so edit button can look them up safely
|
|
||||||
window._scheduleRules = {};
|
|
||||||
rules.forEach(r => { window._scheduleRules[r.id] = r; });
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const upcoming = [];
|
|
||||||
const past = [];
|
|
||||||
rules.forEach(rule => {
|
|
||||||
if (rule.mode === 'specific') {
|
|
||||||
const t = new Date(rule.specific_datetime.replace(' ', 'T')).getTime();
|
|
||||||
(t > now ? upcoming : past).push(rule);
|
|
||||||
} else {
|
|
||||||
upcoming.push(rule);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort upcoming: specific rules soonest first, relative rules after
|
|
||||||
upcoming.sort((a, b) => {
|
|
||||||
const aTime = a.mode === 'specific' ? new Date(a.specific_datetime.replace(' ', 'T')).getTime() : Infinity;
|
|
||||||
const bTime = b.mode === 'specific' ? new Date(b.specific_datetime.replace(' ', 'T')).getTime() : Infinity;
|
|
||||||
return aTime - bTime;
|
|
||||||
});
|
|
||||||
|
|
||||||
countEl.textContent = `${upcoming.length} scheduled`;
|
|
||||||
|
|
||||||
if (upcoming.length === 0) {
|
|
||||||
list.innerHTML = '<p class="text-muted" style="font-size:0.85rem;">No upcoming rules. Add one above.</p>';
|
|
||||||
} else {
|
|
||||||
list.innerHTML = upcoming.map(rule => renderRuleCard(rule, false)).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (past.length > 0) {
|
|
||||||
pastCard.style.display = '';
|
|
||||||
pastCountEl.textContent = `${past.length} past`;
|
|
||||||
pastList.innerHTML = past.map(rule => renderRuleCard(rule, true)).join('');
|
|
||||||
} else {
|
|
||||||
pastCard.style.display = 'none';
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
list.innerHTML = '<p class="text-danger" style="font-size:0.85rem;">Could not load schedule.</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRuleCard(rule, isPast) {
|
|
||||||
const label = ruleLabel(rule);
|
|
||||||
const sendAt = rule.mode === 'specific' ? new Date(rule.specific_datetime.replace(' ', 'T')) : null;
|
|
||||||
const timeTag = sendAt
|
|
||||||
? isPast
|
|
||||||
? `<span style="font-size:0.75rem;background:rgba(139,148,158,0.12);color:var(--text-muted);border-radius:6px;padding:0.15em 0.6em;"><i class="fa-solid fa-circle-check me-1"></i>${formatDateTime(sendAt)}</span>`
|
|
||||||
: `<span style="font-size:0.75rem;background:rgba(79,142,247,0.12);color:var(--accent);border-radius:6px;padding:0.15em 0.6em;">${relativeTime(sendAt)}</span>`
|
|
||||||
: '';
|
|
||||||
const opacity = isPast ? 'opacity:0.6;' : '';
|
|
||||||
return `
|
|
||||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:10px;padding:1rem;${opacity}">
|
|
||||||
<div class="d-flex align-items-start justify-content-between gap-2 mb-1">
|
|
||||||
<div>
|
|
||||||
<div style="font-weight:600;font-size:0.9rem;">${esc(rule.template_name)}</div>
|
|
||||||
<div class="text-muted" style="font-size:0.78rem;">${esc(label)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
${!isPast ? `<button class="btn btn-sm btn-secondary" onclick="openEditModal(${rule.id})"><i class="fa-solid fa-pencil fa-fw"></i></button>` : `<button class="btn btn-sm btn-secondary" onclick="openEditModal(${rule.id})" title="Reschedule"><i class="fa-solid fa-rotate-right fa-fw"></i></button>`}
|
|
||||||
<button class="btn btn-sm btn-danger" onclick="removeSchedule(${rule.id})"><i class="fa-solid fa-trash fa-fw"></i></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
${timeTag}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadTemplateOptions();
|
|
||||||
loadSchedule();
|
|
||||||
setInterval(loadSchedule, 60000);
|
|
||||||
|
|
||||||
// Set default datetime-local to now
|
|
||||||
const pad = n => String(n).padStart(2, '0');
|
|
||||||
const now = new Date();
|
|
||||||
document.getElementById('specificDatetime').value =
|
|
||||||
`${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
async function checkAuth() {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/admin.php', { redirect: 'manual' });
|
|
||||||
if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }
|
|
||||||
const d = await r.json();
|
|
||||||
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
|
|
||||||
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));
|
|
||||||
const name = d.name || d.username;
|
|
||||||
if (name) {
|
|
||||||
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
checkAuth();
|
|
||||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+36
-250
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>Send SMS Now — IMFestival SMS Dashboard</title>
|
<title>Send SMS Now — IMFestival SMS Dashboard</title>
|
||||||
|
|
||||||
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||||
@@ -17,45 +17,8 @@
|
|||||||
|
|
||||||
<!-- ─── Mobile top bar ─── -->
|
<!-- ─── Mobile top bar ─── -->
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
<span style="font-weight:600;font-size:0.95rem;">Send SMS Now</span>
|
||||||
<i class="fa-solid fa-bars"></i>
|
<span class="nav-username ms-auto" style="font-size:0.82rem;color:var(--text-muted);">User</span>
|
||||||
</button>
|
|
||||||
<span style="font-weight:600;font-size:0.95rem;">SMS Dashboard</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ─── Mobile offcanvas sidebar ─── -->
|
|
||||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
|
||||||
<div class="offcanvas-header">
|
|
||||||
<div class="brand"
|
|
||||||
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
|
||||||
<span>IMFestival SMS Dashboard</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="offcanvas-body p-3">
|
|
||||||
<nav class="d-flex flex-column gap-1">
|
|
||||||
<a href="index.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-house fa-fw"></i>
|
|
||||||
Home
|
|
||||||
</a>
|
|
||||||
<a href="attendees.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-users fa-fw"></i>
|
|
||||||
Attendees
|
|
||||||
</a>
|
|
||||||
<a href="templates.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
|
||||||
SMS Templates
|
|
||||||
</a>
|
|
||||||
<a href="schedule.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
|
||||||
Schedule
|
|
||||||
</a>
|
|
||||||
<a href="delivery-log.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
|
||||||
Delivery Log
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── Desktop sidebar ─── -->
|
<!-- ─── Desktop sidebar ─── -->
|
||||||
@@ -103,6 +66,11 @@
|
|||||||
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded from
|
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded from
|
||||||
cost calculations.
|
cost calculations.
|
||||||
</div>
|
</div>
|
||||||
|
<div id="noApiKeyBanner" class="alert alert-danger"
|
||||||
|
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
|
||||||
|
<i class="fa-solid fa-circle-exclamation me-1"></i> No Clickatell API key configured —
|
||||||
|
<a href="index.html" class="alert-link">set one in Admin settings</a> before sending.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="page-header d-flex align-items-center gap-3">
|
<div class="page-header d-flex align-items-center gap-3">
|
||||||
<a href="index.html" class="btn btn-sm btn-secondary">
|
<a href="index.html" class="btn btn-sm btn-secondary">
|
||||||
@@ -173,218 +141,36 @@
|
|||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
<script>
|
<script src="assets/js/nav.js"></script>
|
||||||
const SEND_MAX = 160;
|
<script src="assets/js/app.js"></script>
|
||||||
|
|
||||||
// GSM-7 basic + extended character set
|
|
||||||
const GSM7 = new Set('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà€[]{}\\^|~');
|
|
||||||
const GSM7_EXTENDED = new Set('€[]{}\\^|~');
|
|
||||||
|
|
||||||
function smsEncoding(text) {
|
<nav class="bottom-nav" aria-label="Navigation">
|
||||||
const isUnicode = [...text].some(c => !GSM7.has(c));
|
<a href="index.html" class="bottom-nav-item">
|
||||||
const limit = isUnicode ? 70 : 160;
|
<i class="fa-solid fa-house"></i>
|
||||||
const length = isUnicode ? text.length : [...text].reduce((n, c) => n + (GSM7_EXTENDED.has(c) ? 2 : 1), 0);
|
<span>Home</span>
|
||||||
return { length, limit, isUnicode };
|
</a>
|
||||||
}
|
<a href="attendees.html" class="bottom-nav-item">
|
||||||
|
<i class="fa-solid fa-users"></i>
|
||||||
const msgEl = document.getElementById('message');
|
<span>Attendees</span>
|
||||||
const countEl = document.getElementById('charCount');
|
</a>
|
||||||
const previewEl = document.getElementById('preview');
|
<a href="templates.html" class="bottom-nav-item">
|
||||||
const sectionEl = document.getElementById('previewSection');
|
<i class="fa-solid fa-comment-sms"></i>
|
||||||
const placeholderEl = document.getElementById('previewPlaceholder');
|
<span>Templates</span>
|
||||||
const sendBtn = document.getElementById('sendBtn');
|
</a>
|
||||||
const countSpan = document.getElementById('recipientCount');
|
<a href="schedule.html" class="bottom-nav-item">
|
||||||
const recipientsSel = document.getElementById('recipients');
|
<i class="fa-solid fa-calendar-days"></i>
|
||||||
const resultEl = document.getElementById('sendResult');
|
<span>Schedule</span>
|
||||||
|
</a>
|
||||||
// ── Populate recipients from API ───────────────────────────
|
<a href="delivery-log.html" class="bottom-nav-item">
|
||||||
async function loadRecipients() {
|
<i class="fa-solid fa-list-check"></i>
|
||||||
try {
|
<span>Log</span>
|
||||||
const [attData, wsData] = await Promise.all([
|
</a>
|
||||||
fetch('/api/attendees.php').then(r => r.json()),
|
<a href="admin.html" class="bottom-nav-item admin-nav-link d-none">
|
||||||
fetch('/api/workshops.php').then(r => r.json()),
|
<i class="fa-solid fa-user-shield"></i>
|
||||||
]);
|
<span>Admin</span>
|
||||||
const total = attData.total ?? 0;
|
</a>
|
||||||
const workshops = wsData.workshops ?? [];
|
</nav>
|
||||||
|
|
||||||
const opts = [`<option value="all" data-count="${total}">All attendees (${total})</option>`];
|
|
||||||
// Group sessions by workshop name (case-insensitive)
|
|
||||||
const groups = {};
|
|
||||||
workshops.forEach(ws => {
|
|
||||||
const key = ws.workshop_name.toLowerCase();
|
|
||||||
if (!groups[key]) groups[key] = { label: ws.workshop_name, sessions: [] };
|
|
||||||
groups[key].sessions.push(ws);
|
|
||||||
});
|
|
||||||
const fmtTime = ws => ws.workshop_time
|
|
||||||
? new Date(ws.workshop_time.replace(' ', 'T')).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
|
||||||
: '(no time)';
|
|
||||||
Object.values(groups).forEach(({ label, sessions }) => {
|
|
||||||
if (sessions.length === 1) {
|
|
||||||
const ws = sessions[0];
|
|
||||||
opts.push(`<option value="workshop:${ws.id}" data-count="${ws.attendee_count}">${esc(label)} — ${fmtTime(ws)} (${ws.attendee_count})</option>`);
|
|
||||||
} else {
|
|
||||||
opts.push(`<optgroup label="${esc(label)}">`);
|
|
||||||
sessions.forEach(ws => {
|
|
||||||
opts.push(`<option value="workshop:${ws.id}" data-count="${ws.attendee_count}">${fmtTime(ws)} (${ws.attendee_count})</option>`);
|
|
||||||
});
|
|
||||||
opts.push(`</optgroup>`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
recipientsSel.innerHTML = opts.join('');
|
|
||||||
updateCount();
|
|
||||||
} catch {
|
|
||||||
recipientsSel.innerHTML = '<option value="all" data-count="0">Could not load recipients</option>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCount() {
|
|
||||||
const selected = recipientsSel.options[recipientsSel.selectedIndex];
|
|
||||||
countSpan.textContent = selected?.dataset.count ?? '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
recipientsSel.addEventListener('change', updateCount);
|
|
||||||
|
|
||||||
// ── Message compose ───────────────────────────────────────
|
|
||||||
function previewText(raw) {
|
|
||||||
return raw
|
|
||||||
.replace(/\{name\}/gi, 'Jordan')
|
|
||||||
.replace(/\{first[_ ]name\}/gi, 'Jordan')
|
|
||||||
.replace(/\{workshop\}/gi, 'Axe Throwing')
|
|
||||||
.replace(/\{workshop_time\}/gi, '13:30');
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertPlaceholder(text) {
|
|
||||||
const start = msgEl.selectionStart;
|
|
||||||
const end = msgEl.selectionEnd;
|
|
||||||
const val = msgEl.value;
|
|
||||||
msgEl.value = val.slice(0, start) + text + val.slice(end);
|
|
||||||
msgEl.selectionStart = msgEl.selectionEnd = start + text.length;
|
|
||||||
msgEl.focus();
|
|
||||||
msgEl.dispatchEvent(new Event('input'));
|
|
||||||
}
|
|
||||||
|
|
||||||
msgEl.addEventListener('input', function () {
|
|
||||||
const val = this.value;
|
|
||||||
const { length, limit, isUnicode } = smsEncoding(val);
|
|
||||||
const remaining = limit - length;
|
|
||||||
|
|
||||||
countEl.innerHTML = `${length}/${limit} characters${isUnicode ? ' <span style="color:#f87171;font-size:0.8em;"><i class="fa-solid fa-triangle-exclamation"></i> Unicode encoding — limit reduced to 70</span>' : ''}`;
|
|
||||||
countEl.className = `d-block mb-2 ${remaining <= 10 ? (remaining <= 0 ? 'text-danger' : 'text-warning') : 'text-muted'}`;
|
|
||||||
|
|
||||||
const empty = val.trim() === '';
|
|
||||||
sectionEl.style.visibility = empty ? 'hidden' : 'visible';
|
|
||||||
placeholderEl.style.display = empty ? '' : 'none';
|
|
||||||
previewEl.textContent = empty ? '' : previewText(val);
|
|
||||||
sendBtn.disabled = empty;
|
|
||||||
resultEl.style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Send ───────────────────────────────────────────────────
|
|
||||||
function esc(str) {
|
|
||||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
sendBtn.addEventListener('click', async () => {
|
|
||||||
const message = msgEl.value.trim();
|
|
||||||
if (!message) return;
|
|
||||||
|
|
||||||
const selVal = recipientsSel.value;
|
|
||||||
const isAll = selVal === 'all';
|
|
||||||
const wsId = isAll ? null : parseInt(selVal.split(':')[1]);
|
|
||||||
|
|
||||||
sendBtn.disabled = true;
|
|
||||||
sendBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin me-2"></i>Sending…';
|
|
||||||
resultEl.style.display = 'none';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const selectedTemplateId = templatePicker.value ? parseInt(templatePicker.value) : null;
|
|
||||||
const payload = {
|
|
||||||
recipient_scope: isAll ? 'all' : 'workshop',
|
|
||||||
};
|
|
||||||
if (selectedTemplateId) {
|
|
||||||
payload.template_id = selectedTemplateId;
|
|
||||||
} else {
|
|
||||||
payload.message = message;
|
|
||||||
}
|
|
||||||
if (!isAll) payload.workshop_session_id = wsId;
|
|
||||||
|
|
||||||
const res = await fetch('/api/send-now.php', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error(data.error ?? 'Unknown error');
|
|
||||||
|
|
||||||
resultEl.style.display = '';
|
|
||||||
resultEl.innerHTML = `
|
|
||||||
<div style="background:rgba(34,197,94,0.1);border:1px solid rgba(34,197,94,0.3);border-radius:8px;padding:0.75rem 1rem;color:#4ade80;font-size:0.9rem;">
|
|
||||||
<i class="fa-solid fa-circle-check me-2"></i>
|
|
||||||
Sent to ${data.sent} recipient${data.sent !== 1 ? 's' : ''}.
|
|
||||||
</div>`;
|
|
||||||
msgEl.value = '';
|
|
||||||
msgEl.dispatchEvent(new Event('input'));
|
|
||||||
} catch (err) {
|
|
||||||
resultEl.style.display = '';
|
|
||||||
resultEl.innerHTML = `
|
|
||||||
<div style="background:rgba(220,53,69,0.1);border:1px solid rgba(220,53,69,0.3);border-radius:8px;padding:0.75rem 1rem;color:#f87171;font-size:0.9rem;">
|
|
||||||
<i class="fa-solid fa-circle-xmark me-2"></i>${esc(err.message)}
|
|
||||||
</div>`;
|
|
||||||
} finally {
|
|
||||||
sendBtn.disabled = msgEl.value.trim() === '';
|
|
||||||
sendBtn.innerHTML = '<i class="fa-solid fa-paper-plane me-2"></i>Send to <span id="recipientCount">' + countSpan.textContent + '</span> attendees';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
loadRecipients();
|
|
||||||
|
|
||||||
// ── Template picker ───────────────────────────────────────
|
|
||||||
const templatePicker = document.getElementById('templatePicker');
|
|
||||||
let templateBodies = {}; // id → body
|
|
||||||
|
|
||||||
async function loadTemplatePicker() {
|
|
||||||
try {
|
|
||||||
const data = await fetch('/api/templates.php').then(r => r.json());
|
|
||||||
const templates = data.templates ?? [];
|
|
||||||
templateBodies = {};
|
|
||||||
templates.forEach(t => { templateBodies[t.id] = t.body; });
|
|
||||||
templatePicker.innerHTML = '<option value="">— Custom message —</option>'
|
|
||||||
+ templates.map(t => `<option value="${t.id}">${esc(t.name)}</option>`).join('');
|
|
||||||
} catch {
|
|
||||||
// leave the default option in place
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
templatePicker.addEventListener('change', () => {
|
|
||||||
const body = templateBodies[templatePicker.value];
|
|
||||||
if (body !== undefined) {
|
|
||||||
msgEl.value = body;
|
|
||||||
msgEl.dispatchEvent(new Event('input'));
|
|
||||||
msgEl.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
loadTemplatePicker();
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
async function checkAuth() {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/admin.php', { redirect: 'manual' });
|
|
||||||
if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }
|
|
||||||
const d = await r.json();
|
|
||||||
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
|
|
||||||
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));
|
|
||||||
const name = d.name || d.username;
|
|
||||||
if (name) {
|
|
||||||
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
checkAuth();
|
|
||||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+30
-202
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<title>SMS Templates — IMFestival SMS Dashboard</title>
|
<title>SMS Templates — IMFestival SMS Dashboard</title>
|
||||||
|
|
||||||
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
|
||||||
@@ -17,45 +17,8 @@
|
|||||||
|
|
||||||
<!-- ─── Mobile top bar ─── -->
|
<!-- ─── Mobile top bar ─── -->
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<button class="hamburger" data-bs-toggle="offcanvas" data-bs-target="#mobileSidebar" aria-label="Open menu">
|
|
||||||
<i class="fa-solid fa-bars"></i>
|
|
||||||
</button>
|
|
||||||
<span style="font-weight:600;font-size:0.95rem;">SMS Templates</span>
|
<span style="font-weight:600;font-size:0.95rem;">SMS Templates</span>
|
||||||
</div>
|
<span class="nav-username ms-auto" style="font-size:0.82rem;color:var(--text-muted);">User</span>
|
||||||
|
|
||||||
<!-- ─── Mobile offcanvas sidebar ─── -->
|
|
||||||
<div class="offcanvas offcanvas-start" tabindex="-1" id="mobileSidebar">
|
|
||||||
<div class="offcanvas-header">
|
|
||||||
<div class="brand"
|
|
||||||
style="border:none;margin:0;padding:0;flex-direction:column;align-items:flex-start;gap:0.5rem;">
|
|
||||||
<span>SMS Dashboard</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="offcanvas-body p-3">
|
|
||||||
<nav class="d-flex flex-column gap-1">
|
|
||||||
<a href="index.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-house fa-fw"></i>
|
|
||||||
Home
|
|
||||||
</a>
|
|
||||||
<a href="attendees.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-users fa-fw"></i>
|
|
||||||
Attendees
|
|
||||||
</a>
|
|
||||||
<a href="templates.html" class="nav-link-custom active">
|
|
||||||
<i class="fa-solid fa-comment-sms fa-fw"></i>
|
|
||||||
SMS Templates
|
|
||||||
</a>
|
|
||||||
<a href="schedule.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-calendar-days fa-fw"></i>
|
|
||||||
Schedule
|
|
||||||
</a>
|
|
||||||
<a href="delivery-log.html" class="nav-link-custom">
|
|
||||||
<i class="fa-solid fa-list-check fa-fw"></i>
|
|
||||||
Delivery Log
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ─── Desktop sidebar ─── -->
|
<!-- ─── Desktop sidebar ─── -->
|
||||||
@@ -147,171 +110,36 @@
|
|||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
|
||||||
crossorigin="anonymous"></script>
|
crossorigin="anonymous"></script>
|
||||||
<script>
|
<script src="assets/js/nav.js"></script>
|
||||||
const MAX_CHARS = 160;
|
<script src="assets/js/app.js"></script>
|
||||||
|
|
||||||
// GSM-7 basic + extended character set
|
|
||||||
const GSM7 = new Set('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà€[]{}\\^|~');
|
|
||||||
const GSM7_EXTENDED = new Set('€[]{}\\^|~');
|
|
||||||
|
|
||||||
function smsEncoding(text) {
|
<nav class="bottom-nav" aria-label="Navigation">
|
||||||
const isUnicode = [...text].some(c => !GSM7.has(c));
|
<a href="index.html" class="bottom-nav-item">
|
||||||
const limit = isUnicode ? 70 : 160;
|
<i class="fa-solid fa-house"></i>
|
||||||
const length = isUnicode ? text.length : [...text].reduce((n, c) => n + (GSM7_EXTENDED.has(c) ? 2 : 1), 0);
|
<span>Home</span>
|
||||||
return { length, limit, isUnicode };
|
</a>
|
||||||
}
|
<a href="attendees.html" class="bottom-nav-item">
|
||||||
|
<i class="fa-solid fa-users"></i>
|
||||||
function updateCharCount(text) {
|
<span>Attendees</span>
|
||||||
const { length, limit, isUnicode } = smsEncoding(text);
|
</a>
|
||||||
const remaining = limit - length;
|
<a href="templates.html" class="bottom-nav-item active">
|
||||||
const el = document.getElementById('charCount');
|
<i class="fa-solid fa-comment-sms"></i>
|
||||||
el.innerHTML = `${length}/${limit} characters${isUnicode ? ' <span style="color:#f87171;font-size:0.8em;"><i class="fa-solid fa-triangle-exclamation"></i> Unicode encoding — limit reduced to 70</span>' : ''}`;
|
<span>Templates</span>
|
||||||
el.className = `d-block mb-3 ${remaining <= 10 ? (remaining <= 0 ? 'text-danger' : 'text-warning') : 'text-muted'}`;
|
</a>
|
||||||
}
|
<a href="schedule.html" class="bottom-nav-item">
|
||||||
|
<i class="fa-solid fa-calendar-days"></i>
|
||||||
function updatePreview() {
|
<span>Schedule</span>
|
||||||
const template = document.getElementById('template').value;
|
</a>
|
||||||
const previewEl = document.getElementById('preview');
|
<a href="delivery-log.html" class="bottom-nav-item">
|
||||||
const previewSection = previewEl.closest('section');
|
<i class="fa-solid fa-list-check"></i>
|
||||||
if (template.trim() === '') {
|
<span>Log</span>
|
||||||
previewSection.style.visibility = 'hidden';
|
</a>
|
||||||
previewEl.textContent = '';
|
<a href="admin.html" class="bottom-nav-item admin-nav-link d-none">
|
||||||
} else {
|
<i class="fa-solid fa-user-shield"></i>
|
||||||
previewSection.style.visibility = 'visible';
|
<span>Admin</span>
|
||||||
previewEl.textContent = template
|
</a>
|
||||||
.replaceAll('{name}', 'Jordan')
|
</nav>
|
||||||
.replaceAll('{workshop}', 'Axe Throwing')
|
|
||||||
.replaceAll('{workshop_time}', '13:30');
|
|
||||||
}
|
|
||||||
updateCharCount(template);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveTemplate() {
|
|
||||||
const name = document.getElementById('templateName').value.trim();
|
|
||||||
const body = document.getElementById('template').value.trim();
|
|
||||||
const editId = document.getElementById('editId').value;
|
|
||||||
if (!name || !body) { alert('Please enter a template name and message.'); return; }
|
|
||||||
|
|
||||||
const isEdit = editId !== '';
|
|
||||||
const url = isEdit ? `/api/templates.php?id=${editId}` : '/api/templates.php';
|
|
||||||
const method = isEdit ? 'PUT' : 'POST';
|
|
||||||
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name, body }),
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const err = await res.json();
|
|
||||||
alert('Error: ' + (err.error ?? 'Unknown error'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cancelEdit();
|
|
||||||
loadTemplates();
|
|
||||||
}
|
|
||||||
|
|
||||||
function editTemplate(id, name, body) {
|
|
||||||
document.getElementById('editId').value = id;
|
|
||||||
document.getElementById('templateName').value = name;
|
|
||||||
document.getElementById('template').value = body;
|
|
||||||
document.getElementById('formTitle').textContent = 'Edit Template';
|
|
||||||
document.getElementById('saveBtnLabel').textContent = 'Update Template';
|
|
||||||
document.getElementById('cancelEditBtn').classList.remove('d-none');
|
|
||||||
document.getElementById('template').focus();
|
|
||||||
updatePreview();
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEdit() {
|
|
||||||
document.getElementById('editId').value = '';
|
|
||||||
document.getElementById('templateName').value = '';
|
|
||||||
document.getElementById('template').value = '';
|
|
||||||
document.getElementById('formTitle').textContent = 'New Template';
|
|
||||||
document.getElementById('saveBtnLabel').textContent = 'Save Template';
|
|
||||||
document.getElementById('cancelEditBtn').classList.add('d-none');
|
|
||||||
updatePreview();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteTemplate(id) {
|
|
||||||
if (!confirm('Delete this template?')) return;
|
|
||||||
const res = await fetch(`/api/templates.php?id=${id}`, { method: 'DELETE' });
|
|
||||||
if (!res.ok) { alert('Could not delete template.'); return; }
|
|
||||||
loadTemplates();
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(str) {
|
|
||||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTemplates() {
|
|
||||||
const list = document.getElementById('templateList');
|
|
||||||
const countEl = document.getElementById('templateCount');
|
|
||||||
list.innerHTML = '<p class="text-muted" style="font-size:0.85rem;">Loading…</p>';
|
|
||||||
try {
|
|
||||||
const data = await fetch('/api/templates.php').then(r => r.json());
|
|
||||||
const templates = data.templates ?? [];
|
|
||||||
countEl.textContent = templates.length + ' template' + (templates.length !== 1 ? 's' : '');
|
|
||||||
if (templates.length === 0) {
|
|
||||||
list.innerHTML = '<p class="text-muted" style="font-size:0.85rem;">No templates saved yet. Create one above.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
list.innerHTML = templates.map(t => {
|
|
||||||
const chars = t.body.length;
|
|
||||||
const date = new Date(t.updated_at).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
|
|
||||||
return `
|
|
||||||
<div style="background:var(--bg-surface);border:1px solid var(--border);border-radius:10px;padding:1rem;">
|
|
||||||
<div class="d-flex align-items-start justify-content-between gap-2 mb-2">
|
|
||||||
<div>
|
|
||||||
<div style="font-weight:600;font-size:0.9rem;">${esc(t.name)}</div>
|
|
||||||
<div class="text-muted" style="font-size:0.78rem;">Last edited ${date}</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button class="btn btn-sm btn-accent"
|
|
||||||
onclick='editTemplate(${t.id}, ${JSON.stringify(t.name)}, ${JSON.stringify(t.body)})'>
|
|
||||||
<i class="fa-solid fa-pen fa-fw"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-danger"
|
|
||||||
onclick="deleteTemplate(${t.id})">
|
|
||||||
<i class="fa-solid fa-trash fa-fw"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<section class="bubble-on-surface">
|
|
||||||
<div class="from-them">${esc(t.body.replaceAll('{name}', 'Jordan').replaceAll('{workshop}', 'Axe Throwing').replaceAll('{workshop_time}', '13:30'))}</div>
|
|
||||||
</section>
|
|
||||||
<div class="d-flex align-items-center gap-2 mt-2">
|
|
||||||
<span style="font-size:0.75rem;background:rgba(79,142,247,0.12);color:var(--accent);border-radius:6px;padding:0.15em 0.55em;">${chars}/160 chars</span>
|
|
||||||
${parseInt(t.scheduled_count) > 0 ? `<span style="font-size:0.75rem;background:rgba(74,222,128,0.12);color:#4ade80;border-radius:6px;padding:0.15em 0.55em;"><i class="fa-solid fa-clock me-1"></i>Scheduled</span>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
} catch {
|
|
||||||
list.innerHTML = '<p class="text-danger" style="font-size:0.85rem;">Could not load templates.</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('template').addEventListener('input', updatePreview);
|
|
||||||
updatePreview();
|
|
||||||
loadTemplates();
|
|
||||||
</script>
|
|
||||||
<script>
|
|
||||||
async function checkAuth() {
|
|
||||||
try {
|
|
||||||
const r = await fetch('/api/admin.php', { redirect: 'manual' });
|
|
||||||
if (r.type === 'opaqueredirect' || !r.ok) { window.location.reload(); return; }
|
|
||||||
const d = await r.json();
|
|
||||||
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
|
|
||||||
document.querySelectorAll('.admin-nav-link').forEach(el => el.classList.toggle('d-none', !d.is_admin));
|
|
||||||
const name = d.name || d.username;
|
|
||||||
if (name) {
|
|
||||||
document.querySelectorAll('.nav-username').forEach(el => { el.textContent = name; });
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
checkAuth();
|
|
||||||
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') checkAuth(); });
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user