chore: initial commit

This commit is contained in:
2026-06-06 00:03:51 +01:00
commit 99d7f7d3a4
29 changed files with 5183 additions and 0 deletions
+365
View File
@@ -0,0 +1,365 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Admin — IMFestival SMS Dashboard</title>
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
crossorigin="anonymous">
<link href="assets/css/dashboard.css" rel="stylesheet">
</head>
<body>
<!-- ─── Mobile top bar ─── -->
<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;">Admin</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>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="brand">
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
</div>
<nav class="d-flex flex-column gap-1 mt-2">
<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 class="mt-auto pt-3" style="border-top:1px solid var(--border);">
<div class="nav-link-custom" style="cursor:default;">
<i class="fa-solid fa-circle-user fa-fw"></i>
<span style="font-size:0.82rem;">Admin</span>
</div>
</div>
</div>
<!-- ─── Main content ─── -->
<div class="main">
<div class="main-inner">
<div id="sandboxBanner" class="alert alert-warning"
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded
from
cost calculations.
</div>
<div class="page-header d-flex align-items-center gap-3">
<a href="index.html" class="btn btn-sm btn-secondary">
<i class="fa-solid fa-arrow-left me-1"></i> Back
</a>
<div>
<h1>Admin</h1>
<p>Sandbox mode and data management controls.</p>
</div>
</div>
<!-- Alert area -->
<div id="adminResult" class="alert mb-4" style="display:none;" role="alert"></div>
<!-- ── Sandbox Mode ─────────────────────────────────────────────────────── -->
<div class="card p-4 mb-4">
<div class="d-flex align-items-start justify-content-between mb-3 gap-3">
<div>
<h5 class="mb-1">
<i class="fa-solid fa-flask fa-fw me-1" style="color:var(--text-muted);"></i>
Sandbox Mode
</h5>
<p class="text-muted mb-0" style="font-size:0.85rem;">
When enabled, all new messages are tagged as sandbox. Sandbox messages are excluded from the
success rate and cost calculations on the dashboard.
</p>
</div>
<div class="form-check form-switch flex-shrink-0 mt-1">
<input class="form-check-input" type="checkbox" role="switch" id="sandboxToggle"
style="width:3rem;height:1.5rem;cursor:pointer;" aria-label="Toggle sandbox mode">
</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()">
<i class="fa-solid fa-flask me-1"></i>
Mark all existing sends as sandbox
</button>
</div>
<!-- ── Data Management ───────────────────────────────────────────────────── -->
<div class="card p-4">
<h5 class="mb-4">
<i class="fa-solid fa-database fa-fw me-1" style="color:var(--text-muted);"></i>
Data Management
</h5>
<!-- Delivery Log -->
<div class="mb-4">
<h6 class="mb-1">Delivery Log</h6>
<p class="text-muted mb-2" style="font-size:0.82rem;">
<span id="count-log"></span> entries logged.
</p>
<div class="d-flex flex-wrap align-items-center gap-2">
<button class="btn btn-sm btn-danger" onclick="purge('delivery_log')">
<i class="fa-solid fa-trash-can me-1"></i> Purge All
</button>
<span class="text-muted" style="font-size:0.82rem;">or purge before:</span>
<input type="date" id="purgeBefore" class="form-control form-control-sm"
style="width:auto;min-width:150px;">
<button class="btn btn-sm btn-warning" onclick="purge('delivery_log', true)">
<i class="fa-solid fa-calendar-xmark me-1"></i> Purge Before Date
</button>
</div>
</div>
<hr style="border-color:var(--border);">
<!-- Attendees -->
<div class="mb-4">
<h6 class="mb-1">Attendees &amp; Workshops</h6>
<p class="text-muted mb-2" style="font-size:0.82rem;">
<span id="count-attendees"></span> attendees. Removes all attendees, registrations and
workshop sessions.
</p>
<button class="btn btn-sm btn-danger" onclick="purge('attendees')">
<i class="fa-solid fa-trash-can me-1"></i> Purge All Attendees
</button>
</div>
<hr style="border-color:var(--border);">
<!-- Templates -->
<div class="mb-4">
<h6 class="mb-1">SMS Templates</h6>
<p class="text-muted mb-2" style="font-size:0.82rem;">
<span id="count-templates"></span> templates.
</p>
<button class="btn btn-sm btn-danger" onclick="purge('templates')">
<i class="fa-solid fa-trash-can me-1"></i> Purge All Templates
</button>
</div>
<hr style="border-color:var(--border);">
<!-- Schedules -->
<div>
<h6 class="mb-1">Scheduled Rules</h6>
<p class="text-muted mb-2" style="font-size:0.82rem;">
<span id="count-schedules"></span> scheduled rules.
</p>
<button class="btn btn-sm btn-danger" onclick="purge('schedules')">
<i class="fa-solid fa-trash-can me-1"></i> Purge All Schedules
</button>
</div>
</div>
</div><!-- /.main-inner -->
</div><!-- /.main -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous"></script>
<script>
'use strict';
// ─── Load admin state ────────────────────────────────────────────────────────
async function loadAdmin() {
try {
const data = await fetch('/api/admin.php').then(r => r.json());
document.getElementById('sandboxToggle').checked = data.sandbox_mode;
document.getElementById('sandboxBanner').style.display = data.sandbox_mode ? '' : 'none';
document.getElementById('stat-real').textContent = data.delivery_log_real_sent ?? '—';
document.getElementById('stat-sandbox').textContent = data.delivery_log_sandbox ?? '—';
document.getElementById('stat-total').textContent = data.delivery_log_total ?? '—';
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();
</script>
</body>
</html>
+128
View File
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
try {
match ($_SERVER['REQUEST_METHOD']) {
'GET' => handle_get(),
'POST' => handle_post(),
default => error_out('Method not allowed', 405),
};
} catch (PDOException $e) {
error_out('Database error: ' . $e->getMessage(), 500);
}
// ─── GET /api/admin.php ───────────────────────────────────────────────────────
function handle_get(): never
{
$db = db(); // migrations run inside db()
$sandbox_mode = (bool) (int) ($db->query(
"SELECT setting_value FROM `system` WHERE setting_name = 'sandbox_mode'"
)->fetchColumn());
$counts = $db->query(
"SELECT
COUNT(*) AS total,
SUM(is_sandbox = 1) AS sandbox_count,
SUM(is_sandbox = 0 AND status IN ('sent','delivered')) AS real_sent,
SUM(is_sandbox = 0 AND status = 'failed') AS real_failed
FROM delivery_log"
)->fetch();
json_out([
'sandbox_mode' => $sandbox_mode,
'delivery_log_total' => (int) ($counts['total'] ?? 0),
'delivery_log_sandbox' => (int) ($counts['sandbox_count'] ?? 0),
'delivery_log_real_sent' => (int) ($counts['real_sent'] ?? 0),
'delivery_log_real_failed' => (int) ($counts['real_failed'] ?? 0),
'attendees' => (int) $db->query('SELECT COUNT(*) FROM attendees')->fetchColumn(),
'templates' => (int) $db->query('SELECT COUNT(*) FROM sms_templates')->fetchColumn(),
'schedules' => (int) $db->query('SELECT COUNT(*) FROM scheduled_rules')->fetchColumn(),
]);
}
// ─── POST /api/admin.php ──────────────────────────────────────────────────────
function handle_post(): never
{
$body = json_body();
$action = (string) ($body['action'] ?? '');
match ($action) {
'toggle_sandbox' => action_toggle_sandbox(),
'mark_all_sandbox' => action_mark_all_sandbox(),
'purge' => action_purge($body),
default => error_out('Unknown action'),
};
}
function action_toggle_sandbox(): never
{
$db = db();
$current = (int) $db->query(
"SELECT setting_value FROM `system` WHERE setting_name = 'sandbox_mode'"
)->fetchColumn();
$new = $current ? '0' : '1';
$db->prepare(
"UPDATE `system` SET setting_value = :v WHERE setting_name = 'sandbox_mode'"
)->execute([':v' => $new]);
json_out(['sandbox_mode' => (bool) (int) $new]);
}
function action_mark_all_sandbox(): never
{
db()->exec('UPDATE delivery_log SET is_sandbox = 1');
json_out(['updated' => true]);
}
function action_purge(array $body): never
{
$target = (string) ($body['target'] ?? '');
$db = db();
match ($target) {
'delivery_log' => purge_delivery_log($db, $body),
'attendees' => purge_attendees($db),
'templates' => purge_templates($db),
'schedules' => purge_schedules($db),
default => error_out('Unknown purge target'),
};
}
function purge_delivery_log(PDO $db, array $body): never
{
$before = isset($body['before']) ? trim((string) $body['before']) : '';
if ($before !== '') {
if (strtotime($before) === false) {
error_out('Invalid date');
}
$stmt = $db->prepare('DELETE FROM delivery_log WHERE sent_at < :before');
$stmt->execute([':before' => $before]);
json_out(['purged' => $stmt->rowCount()]);
}
$db->exec('DELETE FROM delivery_log');
json_out(['purged' => true]);
}
function purge_attendees(PDO $db): never
{
$db->exec('DELETE FROM registrations');
$db->exec('DELETE FROM attendees');
$db->exec('DELETE FROM workshop_sessions');
json_out(['purged' => true]);
}
function purge_templates(PDO $db): never
{
$db->exec('DELETE FROM sms_templates');
json_out(['purged' => true]);
}
function purge_schedules(PDO $db): never
{
$db->exec('DELETE FROM scheduled_rules');
json_out(['purged' => true]);
}
+476
View File
@@ -0,0 +1,476 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
try {
match ($_SERVER['REQUEST_METHOD']) {
'GET' => handle_get(),
'POST' => handle_post(),
'PUT' => handle_put(),
'DELETE' => handle_delete(),
default => error_out('Method not allowed', 405),
};
} catch (PDOException $e) {
error_out('Database error: ' . $e->getMessage(), 500);
}
// ─── GET /api/attendees.php ───────────────────────────────────────────────────
// Returns all attendees with their registered workshops.
function handle_get(): never
{
$rows = db()->query(
"SELECT a.id, a.first_name, a.last_name, a.email, a.mobile_number,
GROUP_CONCAT(ws.workshop_name ORDER BY ws.workshop_time SEPARATOR ', ') AS workshops,
GROUP_CONCAT(ws.workshop_time ORDER BY ws.workshop_time SEPARATOR ',') AS workshop_times,
MIN(ws.workshop_time) AS workshop_time,
MIN(r.workshop_session_id) AS workshop_session_id
FROM attendees a
LEFT JOIN registrations r ON r.attendee_id = a.id
LEFT JOIN workshop_sessions ws ON ws.id = r.workshop_session_id
GROUP BY a.id
ORDER BY a.last_name, a.first_name"
)->fetchAll();
json_out(['attendees' => $rows, 'total' => count($rows)]);
}
// ─── POST /api/attendees.php ──────────────────────────────────────────────────
// Accepts a CSV file upload (field name: "file").
// Replaces all existing attendees with the CSV contents.
// Required columns: first_name, last_name, mobile_number
// Optional columns: email
function handle_post(): never
{
if (empty($_FILES['file'])) {
error_out('No file uploaded');
}
$file = $_FILES['file'];
if ($file['error'] !== UPLOAD_ERR_OK) {
error_out('Upload error (code ' . $file['error'] . ')');
}
$ext = strtolower(pathinfo((string) $file['name'], PATHINFO_EXTENSION));
if ($ext !== 'csv') {
error_out('File must be a .csv');
}
$handle = fopen($file['tmp_name'], 'r');
if ($handle === false) {
error_out('Could not read uploaded file');
}
// Auto-detect delimiter from the first line (tab, semicolon, or comma)
// Strip UTF-8 BOM if present so it doesn't interfere with delimiter counting or fgetcsv
$rawFirstLine = (string) fgets($handle);
$hasBom = str_starts_with($rawFirstLine, "\xEF\xBB\xBF");
$firstLine = $hasBom ? substr($rawFirstLine, 3) : $rawFirstLine;
rewind($handle);
if ($hasBom) {
fseek($handle, 3); // seek past BOM so fgetcsv sees the opening quote of the first field
}
$tabCount = substr_count($firstLine, "\t");
$semiCount = substr_count($firstLine, ';');
$commaCount = substr_count($firstLine, ',');
if ($tabCount >= $semiCount && $tabCount >= $commaCount) {
$delim = "\t";
} elseif ($semiCount >= $commaCount) {
$delim = ';';
} else {
$delim = ',';
}
$headers = fgetcsv($handle, 0, $delim);
if ($headers === false) {
error_out('CSV file is empty');
}
$headers = array_map(fn($h) => strtolower(trim((string) $h, " \t\n\r\0\x0B\"")), $headers);
$col = [
'first_name' => find_col($headers, ['first name', 'first_name', 'firstname', 'first']),
'last_name' => find_col($headers, ['last name', 'last_name', 'lastname', 'last', 'surname']),
'email' => find_col($headers, ['email', 'email address', 'email_address']),
'mobile_number' => find_col($headers, ['custom: mobile number', 'mobile_number', 'mobile number', 'mobile', 'phone', 'telephone', 'cell']),
'status' => find_col($headers, ['status', 'rsvp status', 'rsvp_status']),
// Workshop columns — both are optional; time may also be embedded in the name
'workshop_name' => find_col($headers, [
'custom: please pick one workshop you\'d like to book into.',
'custom: please pick one workshop you\'d like to book into',
'workshop_name', 'workshop name', 'workshop', 'session', 'activity',
]),
'workshop_time' => find_col($headers, ['workshop_time', 'workshop time', 'session_time', 'time', 'start_time']),
];
if ($col['first_name'] === null || $col['last_name'] === null || $col['mobile_number'] === null) {
fclose($handle);
$missing = [];
if ($col['first_name'] === null) $missing[] = 'first_name (e.g. "First Name")';
if ($col['last_name'] === null) $missing[] = 'last_name (e.g. "Last Name")';
if ($col['mobile_number'] === null) $missing[] = 'mobile_number (e.g. "Custom: Mobile number")';
error_out('Missing required columns: ' . implode(', ', $missing) . '. Headers detected: ' . implode(' | ', $headers));
}
$hasWorkshops = $col['workshop_name'] !== null;
$mode = trim((string) ($_POST['mode'] ?? 'clear'));
if (!in_array($mode, ['clear', 'overwrite'], true)) {
$mode = 'clear';
}
$db = db();
$db->beginTransaction();
if ($mode === 'clear') {
$db->exec('DELETE FROM registrations');
$db->exec('DELETE FROM attendees');
if ($hasWorkshops) {
$db->exec('DELETE FROM workshop_sessions');
}
}
// Pre-populate workshop cache from existing sessions (for overwrite/append)
$workshopCache = [];
if ($hasWorkshops && $mode !== 'clear') {
foreach ($db->query('SELECT id, workshop_name, workshop_time FROM workshop_sessions')->fetchAll() as $ws) {
$workshopCache[$ws['workshop_name'] . '|' . ($ws['workshop_time'] ?? '')] = (int) $ws['id'];
}
}
$workshopStmt = $db->prepare(
'INSERT INTO workshop_sessions (workshop_name, workshop_time) VALUES (:name, :time)'
);
$regStmt = $db->prepare(
'INSERT INTO registrations (attendee_id, workshop_session_id) VALUES (:attendee_id, :ws_id)'
);
$insertStmt = $db->prepare(
'INSERT INTO attendees (first_name, last_name, email, mobile_number)
VALUES (:first_name, :last_name, :email, :mobile_number)'
);
// Match by email — most stable identifier
$findByEmailStmt = $db->prepare(
"SELECT id FROM attendees WHERE email = :email AND email != ''"
);
// Update all fields including mobile (e.g. number may have changed, matched by email)
$updateStmt = $db->prepare(
'UPDATE attendees SET first_name=:first_name, last_name=:last_name, email=:email, mobile_number=:mobile_number WHERE id=:id'
);
$deleteRegsStmt = $db->prepare(
'DELETE FROM registrations WHERE attendee_id = :attendee_id'
);
$imported = 0;
$updated = 0;
$skipped = 0;
$skipReasons = ['not_attending' => 0, 'no_mobile' => 0];
$rejected = [];
while (($row = fgetcsv($handle, 0, $delim)) !== false) {
// Strip leading/trailing double-quotes that some spreadsheet exports add around field values
$row = array_map(fn($v) => trim((string) $v, " \t\n\r\0\x0B\""), $row);
$firstName = trim((string) ($row[$col['first_name']] ?? ''));
$lastName = trim((string) ($row[$col['last_name']] ?? ''));
$email = trim((string) ($row[$col['email']] ?? ''));
$rawMobile = trim((string) ($row[$col['mobile_number']] ?? ''), " \t\n\r\0\x0B\"'");
$rawStatus = $col['status'] !== null ? trim((string) ($row[$col['status']] ?? '')) : '';
// Skip non-attending rows when a status column is present
if ($col['status'] !== null) {
$status = strtolower($rawStatus);
if ($status !== '' && $status !== 'attending') {
$skipped++;
$skipReasons['not_attending']++;
$rejected[] = [
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'raw_mobile' => $rawMobile,
'status' => $rawStatus,
'reason' => 'not_attending',
];
continue;
}
}
$mobile = sanitise_mobile($rawMobile);
if ($mobile === '') {
$skipped++;
$skipReasons['no_mobile']++;
$rejected[] = [
'first_name' => $firstName,
'last_name' => $lastName,
'email' => $email,
'raw_mobile' => $rawMobile,
'status' => $rawStatus,
'reason' => classify_mobile_rejection($rawMobile),
];
continue;
}
if ($mode === 'clear') {
$insertStmt->execute([
':first_name' => $firstName,
':last_name' => $lastName,
':email' => $email,
':mobile_number' => $mobile,
]);
$attendeeId = (int) $db->lastInsertId();
$imported++;
} else {
// Match by email only
$matches = [];
if ($email !== '') {
$findByEmailStmt->execute([':email' => $email]);
$matches = $findByEmailStmt->fetchAll();
}
if (count($matches) === 0) {
// Not found — insert
$insertStmt->execute([
':first_name' => $firstName,
':last_name' => $lastName,
':email' => $email,
':mobile_number' => $mobile,
]);
$attendeeId = (int) $db->lastInsertId();
$imported++;
} else {
// overwrite: update all fields (incl. mobile), collapse any duplicates
$attendeeId = (int) $matches[0]['id'];
$updateStmt->execute([
':first_name' => $firstName,
':last_name' => $lastName,
':email' => $email,
':mobile_number' => $mobile,
':id' => $attendeeId,
]);
$deleteRegsStmt->execute([':attendee_id' => $attendeeId]);
if (count($matches) > 1) {
$extraIds = array_column(array_slice($matches, 1), 'id');
$placeholders = implode(',', array_fill(0, count($extraIds), '?'));
$db->prepare("DELETE FROM attendees WHERE id IN ($placeholders)")
->execute($extraIds);
}
$updated++;
}
}
// Assign to a workshop session (runs for all modes)
if ($hasWorkshops) {
$wsName = trim((string) ($row[$col['workshop_name']] ?? ''));
if ($wsName !== '') {
// Try separate time column first; otherwise extract time from the name
$wsTime = $col['workshop_time'] !== null
? trim((string) ($row[$col['workshop_time']] ?? ''))
: '';
if ($wsTime === '') {
$wsTime = extract_time_from_string($wsName) ?? '';
// Strip the extracted time token from the workshop name
$wsName = trim_time_from_string($wsName);
}
$dt = null;
if ($wsTime !== '') {
$parsed = strtotime($wsTime);
$dt = $parsed !== false ? date('Y-m-d H:i:s', $parsed) : null;
}
$cacheKey = $wsName . '|' . ($dt ?? '');
if (!isset($workshopCache[$cacheKey])) {
$workshopStmt->execute([':name' => $wsName, ':time' => $dt]);
$workshopCache[$cacheKey] = (int) $db->lastInsertId();
}
$regStmt->execute([
':attendee_id' => $attendeeId,
':ws_id' => $workshopCache[$cacheKey],
]);
}
}
}
fclose($handle);
$db->commit();
json_out(['imported' => $imported, 'updated' => $updated, 'skipped' => $skipped, 'skip_reasons' => $skipReasons, 'workshops_imported' => $hasWorkshops, 'rejected' => $rejected]);
}
// ─── PUT /api/attendees.php ───────────────────────────────────────────────────
// Body: { id, first_name, last_name, email, mobile_number,
// workshop_name, workshop_time (ISO datetime or empty) }
// Updates a single attendee and replaces their workshop registration.
function handle_put(): never
{
$body = json_decode((string) file_get_contents('php://input'), true);
$id = (int) ($body['id'] ?? 0);
$firstName = trim((string) ($body['first_name'] ?? ''));
$lastName = trim((string) ($body['last_name'] ?? ''));
$email = trim((string) ($body['email'] ?? ''));
$rawMobile = trim((string) ($body['mobile_number'] ?? ''));
$workshopName = trim((string) ($body['workshop_name'] ?? ''));
$workshopTime = trim((string) ($body['workshop_time'] ?? ''));
if (!$firstName) error_out('first_name is required');
if (!$lastName) error_out('last_name is required');
$mobile = sanitise_mobile($rawMobile);
if ($mobile === '') error_out('A valid mobile_number is required');
$db = db();
$db->beginTransaction();
if ($id <= 0) {
// Insert new attendee (used when manually adding a previously-rejected row)
$db->prepare(
'INSERT INTO attendees (first_name, last_name, email, mobile_number) VALUES (:fn, :ln, :email, :mobile)'
)->execute([':fn' => $firstName, ':ln' => $lastName, ':email' => $email, ':mobile' => $mobile]);
$id = (int) $db->lastInsertId();
} else {
$check = $db->prepare('SELECT id FROM attendees WHERE id = :id');
$check->execute([':id' => $id]);
if (!$check->fetch()) { $db->rollBack(); error_out('Attendee not found', 404); }
$db->prepare(
'UPDATE attendees SET first_name=:fn, last_name=:ln, email=:email, mobile_number=:mobile WHERE id=:id'
)->execute([':fn' => $firstName, ':ln' => $lastName, ':email' => $email, ':mobile' => $mobile, ':id' => $id]);
$db->prepare('DELETE FROM registrations WHERE attendee_id = :id')->execute([':id' => $id]);
}
$workshopSessionId = isset($body['workshop_session_id']) ? (int) $body['workshop_session_id'] : null;
if ($workshopSessionId !== null && $workshopSessionId > 0) {
$verify = $db->prepare('SELECT id FROM workshop_sessions WHERE id = :id');
$verify->execute([':id' => $workshopSessionId]);
if (!$verify->fetch()) error_out('Workshop session not found', 404);
$db->prepare('INSERT INTO registrations (attendee_id, workshop_session_id) VALUES (:aid, :wsid)')
->execute([':aid' => $id, ':wsid' => $workshopSessionId]);
} elseif ($workshopName !== '') {
$dt = null;
if ($workshopTime !== '') {
$parsed = strtotime($workshopTime);
$dt = $parsed !== false ? date('Y-m-d H:i:s', $parsed) : null;
}
$find = $db->prepare(
'SELECT id FROM workshop_sessions
WHERE workshop_name = :name
AND ((:time IS NULL AND workshop_time IS NULL) OR workshop_time = :time)'
);
$find->execute([':name' => $workshopName, ':time' => $dt]);
$ws = $find->fetch();
if ($ws) {
$wsId = (int) $ws['id'];
} else {
$db->prepare('INSERT INTO workshop_sessions (workshop_name, workshop_time) VALUES (:name, :time)')
->execute([':name' => $workshopName, ':time' => $dt]);
$wsId = (int) $db->lastInsertId();
}
$db->prepare('INSERT INTO registrations (attendee_id, workshop_session_id) VALUES (:aid, :wsid)')
->execute([':aid' => $id, ':wsid' => $wsId]);
}
$db->commit();
json_out(['updated' => true]);
}
// ─── DELETE /api/attendees.php ────────────────────────────────────────────────
// Removes all attendees (and their registrations via CASCADE).
function handle_delete(): never
{
$db = db();
$db->exec('DELETE FROM registrations');
$db->exec('DELETE FROM attendees');
$db->exec('DELETE FROM workshop_sessions');
json_out(['deleted' => true]);
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function find_col(array $headers, array $candidates): ?int
{
foreach ($candidates as $candidate) {
$idx = array_search($candidate, $headers, true);
if ($idx !== false) {
return (int) $idx;
}
}
return null;
}
function sanitise_mobile(string $number): string
{
$stripped = trim($number, " \t\n\r\0\x0B\"'");
$prefix = str_starts_with($stripped, '+') ? '+' : '';
$digits = (string) preg_replace('/\D/', '', $stripped);
$normalised = $prefix . $digits;
// Accept UK mobile numbers only: +447XXXXXXXXX, 07XXXXXXXXX, or 7XXXXXXXXX (9 digits after the prefix)
if (!preg_match('/^(\+447|07|7)\d{9}$/', $normalised)) {
return '';
}
// Normalise all variants to international format: +447XXXXXXXXX
if (str_starts_with($normalised, '07')) {
return '+44' . substr($normalised, 1);
}
if (str_starts_with($normalised, '7')) {
return '+447' . substr($normalised, 1);
}
return $normalised; // already +447…
}
/**
* Returns a rejection reason string for a mobile number that failed sanitise_mobile.
* Reasons: 'no_mobile' | 'foreign_country_code' | 'invalid_number'
*/
function classify_mobile_rejection(string $number): string
{
$stripped = trim($number, " \t\n\r\0\x0B\"'");
$prefix = str_starts_with($stripped, '+') ? '+' : '';
$digits = (string) preg_replace('/\D/', '', $stripped);
$normalised = $prefix . $digits;
if ($normalised === '' || strlen($digits) < 3) {
return 'no_mobile';
}
if (str_starts_with($normalised, '+') && !str_starts_with($normalised, '+44')) {
return 'foreign_country_code';
}
return 'invalid_number';
}
/**
* Extracts the first recognisable time token from a string.
* e.g. "Magic / Illusion Workshop 1pm" → "1pm"
* "Axe Throwing 13:30" → "13:30"
*/
function extract_time_from_string(string $str): ?string
{
if (preg_match('/\b(\d{1,2}:\d{2}\s*(?:am|pm)?|\d{1,2}\s*(?:am|pm))\b/i', $str, $m)) {
return trim($m[1]);
}
return null;
}
/**
* Removes the first recognisable time token (and any surrounding separators/spaces)
* from a string, returning the cleaned name.
* e.g. "Magic / Illusion Workshop 1pm" → "Magic / Illusion Workshop"
*/
function trim_time_from_string(string $str): string
{
$cleaned = preg_replace('/[\s\-–—_,]+\b\d{1,2}:\d{2}\s*(?:am|pm)?\b/i', '', $str);
$cleaned = preg_replace('/[\s\-–—_,]+\b\d{1,2}\s*(?:am|pm)\b/i', '', (string) $cleaned);
return trim((string) $cleaned);
}
+63
View File
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../config.php';
/**
* Returns a shared PDO connection for the current request.
*/
function db(): PDO
{
static $pdo = null;
if ($pdo !== null) {
return $pdo;
}
$pdo = new PDO(
sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME),
DB_USER,
DB_PASS,
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
// Idempotent migrations — safe to run on every boot
$pdo->exec('ALTER TABLE delivery_log ADD COLUMN IF NOT EXISTS is_sandbox TINYINT(1) NOT NULL DEFAULT 0');
$pdo->exec("INSERT IGNORE INTO `system` (setting_name, setting_value) VALUES ('sandbox_mode', '0')");
return $pdo;
}
/**
* Sends a JSON response and exits.
*/
function json_out(mixed $data, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
/**
* Decodes the request body as JSON and returns an array.
*/
function json_body(): array
{
$raw = file_get_contents('php://input');
if ($raw === false || $raw === '') {
return [];
}
return json_decode($raw, true) ?? [];
}
/**
* Sends a JSON error response and exits.
*/
function error_out(string $message, int $status = 400): never
{
json_out(['error' => $message], $status);
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
error_out('Method not allowed', 405);
}
// ─── GET /api/delivery-log.php ────────────────────────────────────────────────
// Optional query params:
// ?page=N (default 1, 50 rows per page)
try {
$db = db();
// Aggregate stats — sandbox sends (is_sandbox=1) are excluded so they don't
// inflate cost calculations or success rate on the dashboard.
$stats = $db->query(
"SELECT
SUM(status IN ('sent','delivered') AND is_sandbox = 0) AS total_sent,
SUM(status = 'failed' AND is_sandbox = 0) AS total_failed
FROM delivery_log"
)->fetch();
$total_attempts = (int) $stats['total_sent'] + (int) $stats['total_failed'];
$stats['success_rate'] = $total_attempts > 0
? round(((int) $stats['total_sent'] / $total_attempts) * 100, 1)
: 0.0;
// Paginated log entries — pass ?all=1 to skip pagination (used by DataTables)
$fetchAll = isset($_GET['all']) && $_GET['all'] === '1';
$perPage = 50;
$page = max(1, (int) ($_GET['page'] ?? 1));
$offset = ($page - 1) * $perPage;
$limitClause = $fetchAll ? '' : "LIMIT $perPage OFFSET $offset";
// $perPage and $offset are cast ints — no injection risk from interpolation
$entries = $db->query(
"SELECT dl.id, dl.mobile_number, dl.status, dl.sent_at, dl.delivered_at, dl.error_message,
CONCAT(a.first_name, ' ', a.last_name) AS attendee_name,
COALESCE(t.name, 'Custom message') AS template_name,
COALESCE(sj.custom_body, t.body) AS message_body,
sj.id AS job_id
FROM delivery_log dl
LEFT JOIN attendees a ON a.id = dl.attendee_id
LEFT JOIN send_jobs sj ON sj.id = dl.send_job_id
LEFT JOIN sms_templates t ON t.id = sj.template_id
ORDER BY dl.sent_at DESC
$limitClause"
)->fetchAll();
$total = (int) $db->query('SELECT COUNT(*) FROM delivery_log')->fetchColumn();
json_out([
'stats' => $stats,
'entries' => $entries,
'total' => $total,
'page' => $fetchAll ? 1 : $page,
'per_page' => $fetchAll ? $total : $perPage,
'pages' => $fetchAll ? 1 : (int) ceil($total / $perPage),
]);
} catch (PDOException $e) {
error_out('Database error: ' . $e->getMessage(), 500);
}
+198
View File
@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
try {
match ($_SERVER['REQUEST_METHOD']) {
'GET' => handle_get(),
'POST' => handle_post(),
'PUT' => handle_put(),
'DELETE' => handle_delete(),
default => error_out('Method not allowed', 405),
};
} catch (PDOException $e) {
error_out('Database error: ' . $e->getMessage(), 500);
}
// ─── GET /api/schedule.php ────────────────────────────────────────────────────
function handle_get(): never
{
$rows = db()->query(
'SELECT sr.id, sr.mode, sr.offset_value, sr.offset_unit,
sr.specific_datetime, sr.created_at,
t.id AS template_id, t.name AS template_name
FROM scheduled_rules sr
JOIN sms_templates t ON t.id = sr.template_id
ORDER BY sr.created_at DESC'
)->fetchAll();
json_out(['rules' => $rows]);
}
// ─── POST /api/schedule.php ───────────────────────────────────────────────────
// Body: {
// "template_id": 1,
// "mode": "relative", -- or "specific"
// "offset_value": 30, -- required for relative
// "offset_unit": "minutes", -- required for relative (minutes|hours|days)
// "specific_datetime": "..." -- required for specific (ISO 8601 or MySQL datetime)
// }
function handle_post(): never
{
$body = json_body();
$templateId = (int) ($body['template_id'] ?? 0);
$mode = (string) ($body['mode'] ?? '');
if ($templateId <= 0) {
error_out('template_id is required');
}
if (!in_array($mode, ['relative', 'specific'], true)) {
error_out('mode must be "relative" or "specific"');
}
$offsetValue = null;
$offsetUnit = null;
$specificDt = null;
if ($mode === 'relative') {
$offsetValue = (int) ($body['offset_value'] ?? 0);
$offsetUnit = (string) ($body['offset_unit'] ?? '');
if ($offsetValue <= 0) {
error_out('offset_value must be a positive integer');
}
if (!in_array($offsetUnit, ['minutes', 'hours', 'days'], true)) {
error_out('offset_unit must be "minutes", "hours", or "days"');
}
} else {
$specificDt = (string) ($body['specific_datetime'] ?? '');
if ($specificDt === '') {
error_out('specific_datetime is required when mode is "specific"');
}
if (strtotime($specificDt) === false) {
error_out('specific_datetime is not a valid date/time');
}
}
// Verify template exists
$check = db()->prepare('SELECT id FROM sms_templates WHERE id = :id');
$check->execute([':id' => $templateId]);
if (!$check->fetch()) {
error_out('Template not found', 404);
}
$stmt = db()->prepare(
'INSERT INTO scheduled_rules (template_id, mode, offset_value, offset_unit, specific_datetime)
VALUES (:template_id, :mode, :offset_value, :offset_unit, :specific_datetime)'
);
$stmt->execute([
':template_id' => $templateId,
':mode' => $mode,
':offset_value' => $offsetValue,
':offset_unit' => $offsetUnit,
':specific_datetime' => $specificDt,
]);
json_out(['id' => (int) db()->lastInsertId()], 201);
}
// ─── PUT /api/schedule.php?id=N ─────────────────────────────────────────────
// Body: same fields as POST
function handle_put(): never
{
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
error_out('Missing or invalid id');
}
$body = json_body();
$templateId = (int) ($body['template_id'] ?? 0);
$mode = (string) ($body['mode'] ?? '');
if ($templateId <= 0) {
error_out('template_id is required');
}
if (!in_array($mode, ['relative', 'specific'], true)) {
error_out('mode must be "relative" or "specific"');
}
$offsetValue = null;
$offsetUnit = null;
$specificDt = null;
if ($mode === 'relative') {
$offsetValue = (int) ($body['offset_value'] ?? 0);
$offsetUnit = (string) ($body['offset_unit'] ?? '');
if ($offsetValue <= 0) {
error_out('offset_value must be a positive integer');
}
if (!in_array($offsetUnit, ['minutes', 'hours', 'days'], true)) {
error_out('offset_unit must be "minutes", "hours", or "days"');
}
} else {
$specificDt = (string) ($body['specific_datetime'] ?? '');
if ($specificDt === '') {
error_out('specific_datetime is required when mode is "specific"');
}
if (strtotime($specificDt) === false) {
error_out('specific_datetime is not a valid date/time');
}
}
$db = db();
$check = $db->prepare('SELECT id FROM sms_templates WHERE id = :id');
$check->execute([':id' => $templateId]);
if (!$check->fetch()) {
error_out('Template not found', 404);
}
$stmt = $db->prepare(
'UPDATE scheduled_rules
SET template_id = :template_id, mode = :mode,
offset_value = :offset_value, offset_unit = :offset_unit,
specific_datetime = :specific_datetime
WHERE id = :id'
);
$stmt->execute([
':template_id' => $templateId,
':mode' => $mode,
':offset_value' => $offsetValue,
':offset_unit' => $offsetUnit,
':specific_datetime' => $specificDt,
':id' => $id,
]);
if ($stmt->rowCount() === 0) {
error_out('Rule not found', 404);
}
// Detach previous send_jobs so the updated rule fires fresh on the next
// scheduler tick (delivery_log history is preserved via the send_jobs FK).
$db->prepare('UPDATE send_jobs SET scheduled_rule_id = NULL WHERE scheduled_rule_id = :id')
->execute([':id' => $id]);
json_out(['updated' => true]);
}
// ─── DELETE /api/schedule.php?id=N ───────────────────────────────────────────
function handle_delete(): never
{
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
error_out('Missing or invalid id');
}
$stmt = db()->prepare('DELETE FROM scheduled_rules WHERE id = :id');
$stmt->execute([':id' => $id]);
if ($stmt->rowCount() === 0) {
error_out('Rule not found', 404);
}
json_out(['deleted' => true]);
}
+120
View File
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
error_out('Method not allowed', 405);
}
// ─── POST /api/send-now.php ───────────────────────────────────────────────────
// Body: {
// "message": "...", -- required if template_id is omitted
// "template_id": 1, -- optional, overrides message
// "recipient_scope": "all", -- "all" (default) or "workshop"
// "workshop_session_id": 2 -- required when recipient_scope is "workshop"
// }
try {
$body = json_body();
$customBody = trim((string) ($body['message'] ?? ''));
$templateId = isset($body['template_id']) ? (int) $body['template_id'] : null;
$scope = (string) ($body['recipient_scope'] ?? 'all');
$workshopId = isset($body['workshop_session_id']) ? (int) $body['workshop_session_id'] : null;
if ($customBody === '' && $templateId === null) {
error_out('Either message or template_id is required');
}
if (!in_array($scope, ['all', 'workshop'], true)) {
error_out('recipient_scope must be "all" or "workshop"');
}
if ($scope === 'workshop' && ($workshopId === null || $workshopId <= 0)) {
error_out('workshop_session_id is required when recipient_scope is "workshop"');
}
if ($customBody !== '' && mb_strlen($customBody) > 160) {
error_out('message must be 160 characters or fewer');
}
$db = db();
// Resolve message text from template if provided
$messageBody = $customBody;
if ($templateId !== null) {
$t = $db->prepare('SELECT body FROM sms_templates WHERE id = :id');
$t->execute([':id' => $templateId]);
$tpl = $t->fetch();
if (!$tpl) {
error_out('Template not found', 404);
}
$messageBody = $tpl['body'];
}
// Fetch recipients
if ($scope === 'all') {
$recipients = $db->query('SELECT id, mobile_number FROM attendees')->fetchAll();
} else {
$stmt = $db->prepare(
'SELECT a.id, a.mobile_number
FROM attendees a
JOIN registrations r ON r.attendee_id = a.id
WHERE r.workshop_session_id = :ws_id'
);
$stmt->execute([':ws_id' => $workshopId]);
$recipients = $stmt->fetchAll();
}
if (empty($recipients)) {
error_out('No recipients found for the selected scope');
}
$db->beginTransaction();
// Create the send job record (worker will update status once it has sent)
$jobStmt = $db->prepare(
'INSERT INTO send_jobs (template_id, custom_body, recipient_scope, workshop_session_id, status)
VALUES (:template_id, :custom_body, :scope, :ws_id, "pending")'
);
$jobStmt->execute([
':template_id' => $templateId,
':custom_body' => $customBody !== '' ? $customBody : null,
':scope' => $scope,
':ws_id' => $workshopId,
]);
$jobId = (int) $db->lastInsertId();
$db->commit();
// Dispatch to Python worker (synchronous — waits for all messages to send)
$ch = curl_init(WORKER_URL . '/send');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode(['job_id' => $jobId]),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($curlError || $httpCode !== 200) {
$db->prepare("UPDATE send_jobs SET status='failed' WHERE id = :id")
->execute([':id' => $jobId]);
error_out('SMS worker unavailable: ' . ($curlError ?: "HTTP $httpCode"), 503);
}
$result = json_decode((string) $response, true) ?? [];
json_out([
'job_id' => $jobId,
'sent' => (int) ($result['sent'] ?? 0),
'failed' => (int) ($result['failed'] ?? 0),
'recipients' => count($recipients),
]);
} catch (PDOException $e) {
if (isset($db) && $db->inTransaction()) {
$db->rollBack();
}
error_out('Database error: ' . $e->getMessage(), 500);
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
try {
match ($_SERVER['REQUEST_METHOD']) {
'GET' => handle_get(),
'POST' => handle_post(),
default => error_out('Method not allowed', 405),
};
} catch (PDOException $e) {
error_out('Database error: ' . $e->getMessage(), 500);
}
// ─── GET /api/settings.php?key=<name> ────────────────────────────────────────
// Returns whether the setting is set; never exposes the full value.
function handle_get(): never
{
$key = trim((string) ($_GET['key'] ?? ''));
if ($key === '') {
error_out('Missing key parameter');
}
$stmt = db()->prepare('SELECT setting_value FROM `system` WHERE setting_name = :key');
$stmt->execute([':key' => $key]);
$row = $stmt->fetch();
$value = ($row !== false) ? $row['setting_value'] : null;
$isSet = $value !== null && $value !== '';
// For non-secret settings (no "key" in the name) return the value directly
$isSensitive = str_contains($key, '_key');
json_out([
'set' => $isSet,
'hint' => $isSet && $isSensitive ? '••••••••' . substr($value, -4) : null,
'value' => $isSensitive ? null : $value,
]);
}
// ─── POST /api/settings.php ───────────────────────────────────────────────────
// Body: { "key": "clickatell_api_key", "value": "..." }
// Upserts the setting value.
function handle_post(): never
{
$body = json_decode((string) file_get_contents('php://input'), true);
$key = trim((string) ($body['key'] ?? ''));
$value = trim((string) ($body['value'] ?? ''));
if ($key === '') {
error_out('Missing key');
}
// Only allow known setting names to prevent arbitrary writes
$allowed = ['clickatell_api_key', 'clickatell_cost_per_sms', 'default_sender_id'];
if (!in_array($key, $allowed, true)) {
error_out('Unknown setting key', 400);
}
$stmt = db()->prepare(
'INSERT INTO `system` (setting_name, setting_value) VALUES (:key, :value)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)'
);
$stmt->execute([':key' => $key, ':value' => $value !== '' ? $value : null]);
json_out(['saved' => true]);
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
try {
match ($_SERVER['REQUEST_METHOD']) {
'GET' => handle_get(),
'POST' => handle_post(),
'PUT' => handle_put(),
'DELETE' => handle_delete(),
default => error_out('Method not allowed', 405),
};
} catch (PDOException $e) {
error_out('Database error: ' . $e->getMessage(), 500);
}
// ─── GET /api/templates.php ───────────────────────────────────────────────────
function handle_get(): never
{
$rows = db()->query(
'SELECT t.id, t.name, t.body, t.created_at, t.updated_at,
COUNT(sr.id) AS scheduled_count
FROM sms_templates t
LEFT JOIN scheduled_rules sr ON sr.template_id = t.id
GROUP BY t.id
ORDER BY t.name'
)->fetchAll();
json_out(['templates' => $rows]);
}
// ─── POST /api/templates.php ──────────────────────────────────────────────────
// Body: { "name": "...", "body": "..." }
function handle_post(): never
{
$body = json_body();
$name = trim((string) ($body['name'] ?? ''));
$text = trim((string) ($body['body'] ?? ''));
if ($name === '') {
error_out('name is required');
}
if ($text === '') {
error_out('body is required');
}
if (mb_strlen($text) > 160) {
error_out('body must be 160 characters or fewer');
}
$stmt = db()->prepare('INSERT INTO sms_templates (name, body) VALUES (:name, :body)');
$stmt->execute([':name' => $name, ':body' => $text]);
$id = (int) db()->lastInsertId();
json_out(['id' => $id, 'name' => $name, 'body' => $text], 201);
}
// ─── PUT /api/templates.php?id=N ─────────────────────────────────────────────
// Body: { "name": "...", "body": "..." }
function handle_put(): never
{
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
error_out('Missing or invalid id');
}
$body = json_body();
$name = trim((string) ($body['name'] ?? ''));
$text = trim((string) ($body['body'] ?? ''));
if ($name === '') {
error_out('name is required');
}
if ($text === '') {
error_out('body is required');
}
if (mb_strlen($text) > 160) {
error_out('body must be 160 characters or fewer');
}
$stmt = db()->prepare('UPDATE sms_templates SET name = :name, body = :body WHERE id = :id');
$stmt->execute([':name' => $name, ':body' => $text, ':id' => $id]);
if ($stmt->rowCount() === 0) {
error_out('Template not found', 404);
}
json_out(['id' => $id, 'name' => $name, 'body' => $text]);
}
// ─── DELETE /api/templates.php?id=N ──────────────────────────────────────────
function handle_delete(): never
{
$id = (int) ($_GET['id'] ?? 0);
if ($id <= 0) {
error_out('Missing or invalid id');
}
$stmt = db()->prepare('DELETE FROM sms_templates WHERE id = :id');
$stmt->execute([':id' => $id]);
if ($stmt->rowCount() === 0) {
error_out('Template not found', 404);
}
json_out(['deleted' => true]);
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
error_out('Method not allowed', 405);
}
// ─── GET /api/workshops.php ───────────────────────────────────────────────────
// Returns all workshop sessions with their registered attendee count.
try {
$rows = db()->query(
'SELECT ws.id, ws.workshop_name, ws.workshop_time,
COUNT(r.id) AS attendee_count
FROM workshop_sessions ws
LEFT JOIN registrations r ON r.workshop_session_id = ws.id
GROUP BY ws.id
ORDER BY ws.workshop_time'
)->fetchAll();
json_out(['workshops' => $rows]);
} catch (PDOException $e) {
error_out('Database error: ' . $e->getMessage(), 500);
}
+47
View File
@@ -0,0 +1,47 @@
section {
display: flex;
justify-content: center;
padding: 0.2rem 0;
}
section div {
max-width: 255px;
word-wrap: break-word;
margin-bottom: 20px;
line-height: 24px;
}
.clear {
clear: both;
}
.from-them {
position: relative;
padding: 10px 20px;
background: #E5E5EA;
border-radius: 25px;
color: black;
}
.from-them:before {
content: "";
position: absolute;
z-index: 2;
bottom: 0px;
left: -7px;
height: 20px;
border-left: 20px solid #E5E5EA;
border-bottom-right-radius: 16px 14px;
-webkit-transform: translate(0, -2px);
}
.from-them:after {
content: "";
position: absolute;
z-index: 3;
bottom: 0px;
left: 4px;
width: 26px;
height: 20px;
background: #1c2230;
border-bottom-right-radius: 10px;
-webkit-transform: translate(-30px, -2px);
}
+414
View File
@@ -0,0 +1,414 @@
:root {
--bg-base: #0d1117;
--bg-surface: #161b22;
--bg-card: #1c2230;
--border: #30363d;
--text-primary: #e6edf3;
--text-muted: #8b949e;
--accent: #4f8ef7;
--accent-hover: #6aa3ff;
}
html, body {
height: 100%;
}
body {
background: var(--bg-base);
color: var(--text-primary);
font-family: system-ui, -apple-system, sans-serif;
}
/* ── Modal ── */
.modal-content {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
}
.modal-header {
border-bottom-color: var(--border);
}
.modal-footer {
border-top-color: var(--border);
}
.modal-title {
color: var(--text-primary);
font-size: 0.95rem;
font-weight: 600;
}
.modal-backdrop {
background-color: #000;
}
.sidebar {
background: var(--bg-surface);
border-right: 1px solid var(--border);
width: 240px;
min-height: 100vh;
padding: 1.25rem 1rem;
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 1040;
display: flex;
flex-direction: column;
}
.sidebar .brand {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
padding-bottom: 1.25rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.sidebar .brand img {
width: 120px;
height: auto;
object-fit: contain;
}
.sidebar .brand span {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-primary);
}
.nav-link-custom {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--text-muted);
text-decoration: none;
padding: 0.55rem 0.75rem;
border-radius: 8px;
font-size: 0.9rem;
transition: background 0.15s, color 0.15s;
}
.nav-link-custom:hover,
.nav-link-custom.active {
background: rgba(79, 142, 247, 0.12);
color: var(--accent);
}
.nav-link-custom i {
flex-shrink: 0;
opacity: 0.7;
width: 1rem;
text-align: center;
}
.nav-link-custom.active i,
.nav-link-custom:hover i {
opacity: 1;
}
/* ── Top bar (mobile) ── */
.topbar {
display: none;
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
padding: 0.75rem 1rem;
position: sticky;
top: 0;
z-index: 1030;
align-items: center;
gap: 0.75rem;
}
/* ── Main ── */
.main {
margin-left: 240px;
padding: 1.75rem 2rem;
min-height: 100vh;
}
.main-inner {
max-width: 960px;
margin: 0 auto;
}
/* ── Page header ── */
.page-header {
margin-bottom: 1.5rem;
}
.page-header h1 {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.page-header p {
color: var(--text-muted);
font-size: 0.875rem;
margin: 0.25rem 0 0;
}
/* ── Cards ── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.3);
}
.card h5 {
color: var(--text-primary);
font-size: 0.95rem;
font-weight: 600;
margin-bottom: 1rem;
}
/* ── Stat cards ── */
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.1rem;
text-align: center;
}
.stat-card .stat-label {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 0.3rem;
}
.stat-card .stat-value {
font-size: 1.9rem;
font-weight: 700;
color: var(--text-primary);
line-height: 1;
}
/* ── Forms ── */
.form-control,
.form-select {
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-primary);
border-radius: 8px;
}
.form-control:focus,
.form-select:focus {
background: var(--bg-surface);
border-color: var(--accent);
color: var(--text-primary);
box-shadow: 0 0 0 3px rgba(79, 142, 247, 0.18);
}
.form-control::placeholder {
color: var(--text-muted);
}
.form-control[type="file"] {
color: var(--text-muted);
}
/* ── Tables ── */
.table {
color: var(--text-primary);
font-size: 0.875rem;
--bs-table-bg: transparent;
--bs-table-striped-bg: rgba(255,255,255,0.03);
--bs-table-border-color: var(--border);
}
.table th {
color: var(--text-muted);
font-weight: 500;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
border-color: var(--border);
}
.table td {
border-color: var(--border);
vertical-align: middle;
}
.table-responsive {
border-radius: 8px;
}
/* ── Buttons ── */
.btn {
border-radius: 8px;
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn-primary:hover, .btn-primary:focus {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-secondary {
background: var(--bg-surface);
border-color: var(--border);
color: var(--text-muted);
}
.btn-secondary:hover, .btn-secondary:focus {
background: rgba(255,255,255,0.05);
border-color: var(--border);
color: var(--text-primary);
}
.btn-danger {
background: rgba(220, 53, 69, 0.12);
border-color: transparent;
color: #f87171;
}
.btn-danger:hover, .btn-danger:focus {
background: rgba(220, 53, 69, 0.22);
border-color: transparent;
color: #f87171;
}
.btn-accent {
background: rgba(79, 142, 247, 0.12);
border-color: transparent;
color: var(--accent);
}
.btn-accent:hover, .btn-accent:focus {
background: rgba(79, 142, 247, 0.22);
border-color: transparent;
color: var(--accent-hover);
}
.btn-success {
border-radius: 8px;
}
/* ── Hamburger toggle ── */
.hamburger {
background: none;
border: 1px solid var(--border);
color: var(--text-primary);
border-radius: 8px;
padding: 0.35rem 0.6rem;
cursor: pointer;
line-height: 1;
font-size: 1rem;
display: flex;
align-items: center;
}
/* ── Offcanvas dark sidebar (mobile) ── */
.offcanvas {
background: var(--bg-surface) !important;
border-right: 1px solid var(--border) !important;
}
.offcanvas-header {
border-bottom: 1px solid var(--border);
}
/* ── Badge ── */
.badge-failed {
background: rgba(220,53,69,0.18);
color: #f87171;
border-radius: 6px;
font-size: 0.8rem;
padding: 0.2em 0.55em;
}
.badge-delivered {
background: rgba(34,197,94,0.15);
color: #4ade80;
border-radius: 6px;
font-size: 0.8rem;
padding: 0.2em 0.55em;
}
/* ── Dropzone ── */
.dropzone {
background: var(--bg-surface);
border: 2px dashed var(--border);
border-radius: 10px;
color: var(--text-muted);
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.15s, background 0.15s;
margin-bottom: 1rem;
cursor: pointer;
}
.dropzone:hover,
.dropzone.dz-drag-hover {
border-color: var(--accent);
background: rgba(79, 142, 247, 0.06);
}
.dropzone .dz-message {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
margin: 0;
pointer-events: none;
}
.dropzone .dz-message .dz-icon {
font-size: 1.75rem;
color: var(--text-muted);
}
.dropzone .dz-message span {
font-size: 0.875rem;
}
.dropzone .dz-preview .dz-filename span,
.dropzone .dz-preview .dz-size {
color: var(--text-primary);
}
.dropzone .dz-preview .dz-progress .dz-upload {
background: var(--accent);
}
/* ── Section header ── */
.section-title {
font-size: 1.05rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 1.25rem;
}
/* ── Chat bubble tail override when sitting on --bg-surface ── */
.bubble-on-surface .from-them:after {
background: var(--bg-surface);
}
/* ── Responsive ── */
@media (max-width: 767.98px) {
.sidebar { display: none; }
.topbar { display: flex; }
.main { margin-left: 0; padding: 1rem; }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long
+652
View File
@@ -0,0 +1,652 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Attendees — IMFestival SMS Dashboard</title>
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
crossorigin="anonymous">
<link href="assets/css/dashboard.css" rel="stylesheet">
<link href="https://unpkg.com/dropzone@6.0.0-beta.1/dist/dropzone.css" rel="stylesheet">
<link href="https://cdn.datatables.net/2.3.8/css/dataTables.dataTables.min.css" rel="stylesheet">
</head>
<body>
<!-- ─── Mobile top bar ─── -->
<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;">Attendees</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;">
<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="brand">
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
</div>
<nav class="d-flex flex-column gap-1 mt-2">
<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 class="mt-auto pt-3" style="border-top:1px solid var(--border);">
<div class="nav-link-custom" style="cursor:default;">
<i class="fa-solid fa-circle-user fa-fw"></i>
<span style="font-size:0.82rem;">Admin</span>
</div>
</div>
</div>
<!-- ─── Main content ─── -->
<div class="main">
<div class="main-inner">
<div id="sandboxBanner" class="alert alert-warning"
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded
from
cost calculations.
</div>
<div class="page-header">
<h1>Attendees</h1>
<p>Upload a CSV file to import participants.</p>
</div>
<div class="card p-4 mb-4">
<h5 class="mb-3">Import CSV</h5>
<div class="mb-3 d-flex flex-wrap gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="importMode" id="modeClear" value="clear"
checked>
<label class="form-check-label" for="modeClear">
<strong>Clear &amp; Import</strong>
<span class="text-muted d-block" style="font-size:0.78rem;">Delete all existing attendees,
then import</span>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="importMode" id="modeOverwrite"
value="overwrite">
<label class="form-check-label" for="modeOverwrite">
<strong>Overwrite Existing</strong>
<span class="text-muted d-block" style="font-size:0.78rem;">Update existing attendees, add
new ones</span>
</label>
</div>
</div>
<div id="csvDropzone" class="dropzone">
<div class="dz-message">
<i class="fa-solid fa-file-csv dz-icon"></i>
<span>Drop a CSV here or <strong>click to browse</strong></span>
<span class="text-muted" style="font-size:0.78rem;">Only .csv files accepted · Max 5 MB</span>
</div>
</div>
<div id="confirmUploadBar" class="d-none mt-2 d-flex align-items-center gap-2">
<span id="confirmUploadFilename" class="text-muted" style="font-size:0.85rem;"></span>
<button class="btn btn-sm btn-primary" onclick="confirmUpload()">
<i class="fa-solid fa-upload fa-fw"></i> Confirm Upload
</button>
<button class="btn btn-sm btn-secondary" onclick="cancelUpload()">
Cancel
</button>
</div>
<div id="importResult" class="mt-2" style="font-size:0.82rem;"></div>
</div>
<!-- ─── Rejected records (shown after import if rows were skipped) ─── -->
<div id="rejectedSection" class="card p-4 mb-4 d-none">
<div class="d-flex align-items-center justify-content-between mb-3 flex-wrap gap-2">
<h5 class="mb-0">
<i class="fa-solid fa-circle-exclamation fa-fw me-1" style="color:#facc15;"></i>
Rejected Records <span id="rejectedCount" class="badge ms-1"
style="background:rgba(250,204,21,0.15);color:#facc15;font-size:0.75rem;"></span>
</h5>
<div class="d-flex gap-2 flex-wrap align-items-center">
<div class="btn-group btn-group-sm" id="rejectedFilter" role="group"
aria-label="Filter rejected records">
<button type="button" class="btn btn-secondary" data-filter="all">All</button>
<button type="button" class="btn btn-secondary" data-filter="not_attending">Not
Attending</button>
<button type="button" class="btn btn-secondary active" data-filter="no_mobile">Missing
Mobile</button>
</div>
<button class="btn btn-sm btn-secondary" onclick="dismissAllRejected()">
<i class="fa-solid fa-xmark fa-fw"></i> Dismiss All
</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Mobile (raw)</th>
<th>Status</th>
<th>Reason</th>
<th></th>
</tr>
</thead>
<tbody id="rejectedBody">
<tr>
<td colspan="6" class="text-muted text-center py-3">No rejected records.</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card p-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<h5 class="mb-0">Participants</h5>
<div class="d-flex align-items-center gap-3">
<span class="text-muted" id="attendeeCount" style="font-size:0.82rem;"></span>
<button class="btn btn-sm btn-danger" id="purgeBtn" onclick="purgeAttendees()">
<i class="fa-solid fa-trash-can fa-fw"></i> Purge All
</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-striped mb-0" id="attendeesTable">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Mobile</th>
<th>Workshop</th>
<th>Time</th>
<th></th>
</tr>
</thead>
<tbody id="attendeeBody">
<tr>
<td colspan="6" class="text-muted text-center py-4">Loading…</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous"></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"
integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></script>
<script src="https://cdn.datatables.net/2.3.8/js/dataTables.min.js"></script>
<script>
Dropzone.autoDiscover = false;
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) => {
dz.removeFile(file);
const r = typeof response === 'string' ? JSON.parse(response) : response;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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;
}
}
</script>
<!-- ─── Edit attendee modal ─── -->
<div class="modal fade" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editModalLabel">
<i class="fa-solid fa-pen fa-fw me-2"></i>Edit Attendee
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-6">
<label class="form-label">First Name</label>
<input type="text" class="form-control" id="editFirstName">
</div>
<div class="col-6">
<label class="form-label">Last Name</label>
<input type="text" class="form-control" id="editLastName">
</div>
<div class="col-12">
<label class="form-label">Email</label>
<input type="email" class="form-control" id="editEmail">
</div>
<div class="col-12">
<label class="form-label">Mobile Number</label>
<input type="text" class="form-control" id="editMobile">
</div>
<div class="col-12">
<label class="form-label">Workshop</label>
<select class="form-select" id="editWorkshop">
<option value="">— No workshop —</option>
</select>
</div>
</div>
<div id="editError" class="text-danger mt-3" style="font-size:0.82rem;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="editSaveBtn" onclick="saveEdit()">
<i class="fa-solid fa-floppy-disk fa-fw"></i> Save
</button>
</div>
</div>
</div>
</div>
<script>
(async function () {
try {
const d = await fetch('/api/admin.php').then(r => r.json());
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
} catch { }
}());
</script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
/**
* Loads .env from the project root and defines DB_* constants.
*
* Running via PHP dev server (php -S localhost:8000 from web/):
* The DB container port is exposed on localhost:3306, so DB_HOST resolves
* to 'localhost' automatically when not inside Docker.
*
* Running via Docker Compose (docker compose up):
* DB_HOST stays as 'db' (the service name) from the .env file.
*/
// Parse .env from project root (one level above web/)
$envFile = __DIR__ . '/../.env';
if (is_readable($envFile)) {
foreach (file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#' || !str_contains($line, '=')) {
continue;
}
[$key, $val] = explode('=', $line, 2);
$_ENV[trim($key)] = trim($val);
}
}
// When running the PHP built-in dev server on the host, 'db' won't resolve —
// fall back to localhost (the container port is exposed via docker-compose).
$host = $_ENV['MYSQL_HOST'] ?? 'localhost';
if ($host === 'db' && !file_exists('/.dockerenv')) {
$host = 'localhost';
}
define('DB_HOST', $host);
define('DB_NAME', $_ENV['MYSQL_DATABASE'] ?? 'imf_sms');
define('DB_USER', $_ENV['MYSQL_USER'] ?? 'imf_user');
define('DB_PASS', $_ENV['MYSQL_PASSWORD'] ?? '');
// SMS worker URL — falls back to localhost when running outside Docker.
$workerUrl = $_ENV['WORKER_URL'] ?? 'http://worker:5000';
if (str_contains($workerUrl, '//worker') && !file_exists('/.dockerenv')) {
$workerUrl = 'http://localhost:5000';
}
define('WORKER_URL', $workerUrl);
+257
View File
@@ -0,0 +1,257 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Delivery Log — IMFestival SMS Dashboard</title>
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
crossorigin="anonymous">
<link href="assets/css/dashboard.css" rel="stylesheet">
<link href="https://cdn.datatables.net/2.3.8/css/dataTables.dataTables.min.css" rel="stylesheet">
</head>
<body>
<!-- ─── Mobile top bar ─── -->
<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>
</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 active">
<i class="fa-solid fa-list-check fa-fw"></i>
Delivery Log
</a>
</nav>
</div>
</div>
<!-- ─── Desktop sidebar ─── -->
<div class="sidebar">
<div class="brand">
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
</div>
<nav class="d-flex flex-column gap-1 mt-2">
<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 class="mt-auto pt-3" style="border-top:1px solid var(--border);">
<div class="nav-link-custom" style="cursor:default;">
<i class="fa-solid fa-circle-user fa-fw"></i>
<span style="font-size:0.82rem;">Admin</span>
</div>
</div>
</div>
<!-- ─── Main content ─── -->
<div class="main">
<div class="main-inner">
<div id="sandboxBanner" class="alert alert-warning"
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded from
cost calculations.
</div>
<div class="page-header">
<h1>Delivery Log</h1>
<p>A record of all messages sent and failed.</p>
</div>
<!-- Summary stats -->
<div class="row g-3 mb-4">
<div class="col-6 col-sm-4">
<div class="stat-card">
<div class="stat-label">Sent</div>
<div class="stat-value" id="stat-sent" style="color:#4ade80;"></div>
</div>
</div>
<div class="col-6 col-sm-4">
<div class="stat-card">
<div class="stat-label">Failed</div>
<div class="stat-value" id="stat-failed" style="color:#f87171;"></div>
</div>
</div>
<div class="col-6 col-sm-4">
<div class="stat-card">
<div class="stat-label">Success Rate</div>
<div class="stat-value" id="stat-rate"></div>
</div>
</div>
</div>
<div class="card p-4">
<div class="table-responsive">
<table class="table table-striped mb-0" id="logTable">
<thead>
<tr>
<th>Recipient</th>
<th>Template</th>
<th>Mobile</th>
<th>Status</th>
<th>Sent at</th>
</tr>
</thead>
<tbody id="logBody">
<tr>
<td colspan="5" class="text-muted text-center py-4">Loading…</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-4.0.0.min.js"
integrity="sha256-OaVG6prZf4v69dPg6PhVattBXkcOWQB62pdZ3ORyrao=" crossorigin="anonymous"></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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function errorDesc(msg) {
if (!msg) return '';
try {
const parsed = JSON.parse(msg);
// Top-level error (older Clickatell responses)
if (parsed.error) return parsed.error;
// Clickatell v2: { messages: [{ error, errorDescription }] }
const m = Array.isArray(parsed.messages) ? parsed.messages[0] : null;
if (m?.error) return m.error;
if (m?.errorDescription) return m.errorDescription;
return parsed.message || msg;
} catch {
return msg;
}
}
function fmtDate(str) {
if (!str) return '—';
return new Date(str).toLocaleString('en-GB', {
day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
async function loadLog() {
const tbody = document.getElementById('logBody');
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 () {
try {
const d = await fetch('/api/admin.php').then(r => r.json());
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
} catch { }
}());
</script>
</body>
</html>
+344
View File
@@ -0,0 +1,344 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Home — IMFestival SMS Dashboard</title>
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
crossorigin="anonymous">
<link href="assets/css/dashboard.css" rel="stylesheet">
</head>
<body>
<!-- ─── Mobile top bar ─── -->
<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>
</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 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>
<!-- ─── Desktop sidebar ─── -->
<div class="sidebar">
<div class="brand">
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
</div>
<nav class="d-flex flex-column gap-1 mt-2">
<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 class="mt-auto pt-3" style="border-top:1px solid var(--border);">
<div class="nav-link-custom" style="cursor:default;">
<i class="fa-solid fa-circle-user fa-fw"></i>
<span style="font-size:0.82rem;">Admin</span>
</div>
</div>
</div>
<!-- ─── Main content ─── -->
<div class="main">
<div class="main-inner">
<div id="sandboxBanner" class="alert alert-warning"
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded from
cost calculations.
</div>
<div class="page-header">
<h1>Overview</h1>
<p>Welcome back. Here's what's happening today.</p>
</div>
<!-- Stats -->
<div class="row g-3 mb-4">
<div class="col-12 col-sm-3">
<div class="stat-card border-0">
<div class="stat-label">Attendees</div>
<div class="stat-value" id="stat-attendees"></div>
</div>
</div>
<div class="col-12 col-sm-3">
<div class="stat-card border-0">
<div class="stat-label">Total Sent</div>
<div class="stat-value" id="stat-sent"></div>
</div>
</div>
<div class="col-12 col-sm-3">
<div class="stat-card border-0">
<div class="stat-label">Success Rate</div>
<div class="stat-value" id="stat-rate"></div>
</div>
</div>
<div class="col-12 col-sm-3">
<div class="stat-card border-0">
<div class="stat-label">Estimated Cost</div>
<div class="stat-value" id="stat-cost"></div>
</div>
</div>
</div>
<!-- Quick links -->
<div class="row g-3">
<!-- Send SMS Now — primary action card, full width -->
<div class="col-12">
<a href="send-now.html" class="card p-4 text-decoration-none d-flex flex-row align-items-center gap-3"
style="border-color:var(--accent);background:linear-gradient(135deg,#1a2540 0%,var(--bg-card) 100%);">
<i class="fa-solid fa-paper-plane fa-lg" style="color:var(--accent);"></i>
<div>
<div style="font-weight:600;color:var(--text-primary);">Send SMS Now</div>
<div style="font-size:0.82rem;color:var(--text-muted);">Compose and send a one-off message instantly</div>
</div>
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
</a>
</div>
<div class="col-12 col-sm-6">
<a href="attendees.html"
class="card p-4 border-0 text-decoration-none d-flex flex-row align-items-center gap-3">
<i class="fa-solid fa-users fa-lg" style="color:var(--accent);"></i>
<div>
<div style="font-weight:600;color:var(--text-primary);">Attendees</div>
<div style="font-size:0.82rem;color:var(--text-muted);">Upload and manage participant CSV</div>
</div>
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
</a>
</div>
<div class="col-12 col-sm-6">
<a href="templates.html"
class="card p-4 border-0 text-decoration-none d-flex flex-row align-items-center gap-3">
<i class="fa-solid fa-comment-sms fa-lg" style="color:var(--accent);"></i>
<div>
<div style="font-weight:600;color:var(--text-primary);">SMS Templates</div>
<div style="font-size:0.82rem;color:var(--text-muted);">Create and preview message templates</div>
</div>
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
</a>
</div>
<div class="col-12 col-sm-6">
<a href="schedule.html"
class="card p-4 border-0 text-decoration-none d-flex flex-row align-items-center gap-3">
<i class="fa-solid fa-calendar-days fa-lg" style="color:var(--accent);"></i>
<div>
<div style="font-weight:600;color:var(--text-primary);">Schedule</div>
<div style="font-size:0.82rem;color:var(--text-muted);">Set send rules and timing</div>
</div>
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
</a>
</div>
<div class="col-12 col-sm-6">
<a href="delivery-log.html"
class="card p-4 border-0 text-decoration-none d-flex flex-row align-items-center gap-3">
<i class="fa-solid fa-list-check fa-lg" style="color:var(--accent);"></i>
<div>
<div style="font-weight:600;color:var(--text-primary);">Delivery Log</div>
<div style="font-size:0.82rem;color:var(--text-muted);">Track sent, delivered and failed messages</div>
</div>
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
</a>
</div>
<!-- Start Admin Settings -->
<hr class="mt-4 mb-3">
<div class="page-header mt-0 mb-0">
<p><em>You're seeing these options because you're an admin. Proceed with caution!</em></p>
</div>
<div class="col-12 col-sm-6">
<a href="admin.html" class="card p-4 border-0 text-decoration-none d-flex flex-row align-items-center gap-3">
<i class="fa-solid fa-user-shield fa-lg" style="color:var(--accent);"></i>
<div>
<div style="font-weight:600;color:var(--text-primary);">Admin</div>
<div style="font-size:0.82rem;color:var(--text-muted);">Sandbox mode and data management controls</div>
</div>
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
</a>
</div>
<div class="col-12 col-sm-6">
<button type="button" onclick="openApiKeyModal()" data-bs-toggle="modal" data-bs-target="#apiKeyModal"
class="card p-4 text-decoration-none d-flex flex-row align-items-center gap-3 w-100 border-0 text-start"
style="cursor:pointer;">
<i class="fa-solid fa-cog fa-lg" style="color:var(--accent);"></i>
<div>
<div style="font-weight:600;color:var(--text-primary);">Clickatell Settings</div>
<div style="font-size:0.82rem;color:var(--text-muted);">Change Clickatell API key and cost per SMS</div>
</div>
<i class="fa-solid fa-chevron-right ms-auto" style="color:var(--text-muted);font-size:0.8rem;"></i>
</button>
</div>
</div>
</div>
</div>
<!-- ─── Clickatell API Key modal ─── -->
<div class="modal fade" id="apiKeyModal" tabindex="-1" aria-labelledby="apiKeyModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="apiKeyModalLabel">
<i class="fa-solid fa-gear fa-fw me-2"></i>Clickatell Settings
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p id="apiKeyStatus" class="text-muted mb-3" style="font-size:0.85rem;">Checking…</p>
<div class="mb-3">
<label for="apiKeyInput" class="form-label">API Key</label>
<input type="password" class="form-control" id="apiKeyInput" placeholder="Enter new API key"
autocomplete="off">
</div>
<div class="mb-1">
<label for="costPerSmsInput" class="form-label">Cost per SMS (£)</label>
<input type="number" class="form-control" id="costPerSmsInput" placeholder="e.g. 0.04849" step="0.00001"
min="0">
</div>
<div id="apiKeyError" class="text-danger mt-2" style="font-size:0.82rem;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="apiKeySaveBtn" onclick="saveApiKey()">
<i class="fa-solid fa-floppy-disk fa-fw"></i> Save
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous"></script>
<script>
function esc(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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();
loadStats();
} catch (e) {
errEl.textContent = e.message;
} finally {
btn.disabled = false;
}
}
async function loadStats() {
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 () {
try {
const d = await fetch('/api/admin.php').then(r => r.json());
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
} catch { }
}());
</script>
</body>
</html>
+513
View File
@@ -0,0 +1,513 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Schedule — IMFestival SMS Dashboard</title>
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
crossorigin="anonymous">
<link href="assets/css/dashboard.css" rel="stylesheet">
<style>
.mode-btn {
background: var(--bg-surface);
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: 8px;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.mode-btn.active-mode {
background: rgba(79, 142, 247, 0.14);
border-color: var(--accent);
color: var(--accent);
}
</style>
</head>
<body>
<!-- ─── Mobile top bar ─── -->
<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>
</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 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>
<!-- ─── Desktop sidebar ─── -->
<div class="sidebar">
<div class="brand">
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
</div>
<nav class="d-flex flex-column gap-1 mt-2">
<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 class="mt-auto pt-3" style="border-top:1px solid var(--border);">
<div class="nav-link-custom" style="cursor:default;">
<i class="fa-solid fa-circle-user fa-fw"></i>
<span style="font-size:0.82rem;">Admin</span>
</div>
</div>
</div>
<!-- ─── Main content ─── -->
<div class="main">
<div class="main-inner">
<div id="sandboxBanner" class="alert alert-warning"
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded from
cost calculations.
</div>
<div class="page-header">
<h1>Schedule</h1>
<p>Configure when messages are automatically sent to attendees.</p>
</div>
<div class="card p-4">
<h5>Schedule an SMS message</h5>
<!-- Timing mode toggle -->
<label class="form-label text-muted" style="font-size:0.82rem;">Send timing</label>
<div class="d-flex gap-2 mb-3">
<button type="button" id="modeSpecific" class="btn btn-sm mode-btn active-mode" onclick="setMode('specific')">
<i class="fa-solid fa-calendar-days me-1"></i> Specific date &amp; time
</button>
<button type="button" id="modeRelative" class="btn btn-sm mode-btn" onclick="setMode('relative')">
<i class="fa-solid fa-clock me-1"></i> Relative to workshop
</button>
</div>
<!-- Specific date/time options -->
<div id="specificFields">
<label class="form-label text-muted" style="font-size:0.82rem;">Send at</label>
<input type="datetime-local" class="form-control mb-3" id="specificDatetime">
</div>
<!-- Relative options -->
<div id="relativeFields" style="display:none;">
<label class="form-label text-muted" style="font-size:0.82rem;">Offset</label>
<div class="d-flex gap-2 mb-3">
<input type="number" class="form-control" id="offsetValue" value="1" min="1" style="width:90px;">
<select class="form-select" id="offsetUnit">
<option value="minutes">Minute(s) before</option>
<option value="hours" selected>Hour(s) before</option>
<option value="days">Day(s) before</option>
</select>
</div>
</div>
<label class="form-label text-muted" style="font-size:0.82rem;">Template to use</label>
<select class="form-select mb-3" id="templateSelect">
<option value="" disabled selected>Loading templates…</option>
</select>
<button class="btn btn-primary" onclick="addSchedule()">
<i class="fa-solid fa-plus me-1"></i> Add to Schedule
</button>
</div>
<!-- Scheduled sends -->
<div class="card p-4 mt-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<h5 class="mb-0">Sending soon</h5>
<span class="text-muted" id="scheduleCount" style="font-size:0.82rem;">3 scheduled</span>
</div>
<div class="d-flex flex-column gap-3" id="scheduleList">
<!-- populated by JS -->
</div>
</div>
<!-- Previously scheduled -->
<div class="card p-4 mt-4" id="pastCard" style="display:none;">
<div class="d-flex align-items-center justify-content-between mb-3">
<h5 class="mb-0" style="color:var(--text-muted);">Previously Scheduled</h5>
<span class="text-muted" id="pastCount" style="font-size:0.82rem;"></span>
</div>
<div class="d-flex flex-column gap-3" id="pastList">
<!-- populated by JS -->
</div>
</div>
</div>
</div>
<!-- ─── Edit Rule Modal ─── -->
<div class="modal fade" id="editModal" tabindex="-1" aria-labelledby="editModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editModalLabel">Edit Scheduled Rule</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="editId">
<label class="form-label text-muted" style="font-size:0.82rem;">Send timing</label>
<div class="d-flex gap-2 mb-3">
<button type="button" id="editModeSpecific" class="btn btn-sm mode-btn active-mode"
onclick="setEditMode('specific')">
<i class="fa-solid fa-calendar-days me-1"></i> Specific date &amp; time
</button>
<button type="button" id="editModeRelative" class="btn btn-sm mode-btn" onclick="setEditMode('relative')">
<i class="fa-solid fa-clock me-1"></i> Relative to workshop
</button>
</div>
<div id="editSpecificFields">
<label class="form-label text-muted" style="font-size:0.82rem;">Send at</label>
<input type="datetime-local" class="form-control mb-3" id="editSpecificDatetime">
</div>
<div id="editRelativeFields" style="display:none;">
<label class="form-label text-muted" style="font-size:0.82rem;">Offset</label>
<div class="d-flex gap-2 mb-3">
<input type="number" class="form-control" id="editOffsetValue" value="1" min="1" style="width:90px;">
<select class="form-select" id="editOffsetUnit">
<option value="minutes">Minute(s) before</option>
<option value="hours">Hour(s) before</option>
<option value="days">Day(s) before</option>
</select>
</div>
</div>
<label class="form-label text-muted" style="font-size:0.82rem;">Template to use</label>
<select class="form-select mb-1" id="editTemplateSelect"></select>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveEdit()">
<i class="fa-solid fa-floppy-disk me-1"></i> Save changes
</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous"></script>
<script>
// ── Mode toggle ──────────────────────────────────────────
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 ───────────────────────────
async function loadTemplateOptions() {
const sel = document.getElementById('templateSelect');
try {
const data = await fetch('/api/templates.php').then(r => r.json());
const templates = data.templates ?? [];
if (templates.length === 0) {
sel.innerHTML = '<option value="" disabled selected>No templates — create one first</option>';
return;
}
sel.innerHTML = templates.map(t =>
`<option value="${t.id}">${esc(t.name)}</option>`
).join('');
} catch {
sel.innerHTML = '<option value="" disabled selected>Could not load templates</option>';
}
}
// ── Add a rule ───────────────────────────────────────────
async function addSchedule() {
const templateId = document.getElementById('templateSelect').value;
if (!templateId) { alert('Please select a template.'); return; }
const payload = { template_id: parseInt(templateId), mode: currentMode };
if (currentMode === 'relative') {
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 () {
try {
const d = await fetch('/api/admin.php').then(r => r.json());
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
} catch { }
}());
</script>
</body>
</html>
+381
View File
@@ -0,0 +1,381 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Send SMS Now — IMFestival SMS Dashboard</title>
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
crossorigin="anonymous">
<link href="assets/css/dashboard.css" rel="stylesheet">
<link href="assets/css/chat.css" rel="stylesheet">
</head>
<body>
<!-- ─── Mobile top bar ─── -->
<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>
</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>
<!-- ─── Desktop sidebar ─── -->
<div class="sidebar">
<div class="brand">
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
</div>
<nav class="d-flex flex-column gap-1 mt-2">
<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 class="mt-auto pt-3" style="border-top:1px solid var(--border);">
<div class="nav-link-custom" style="cursor:default;">
<i class="fa-solid fa-circle-user fa-fw"></i>
<span style="font-size:0.82rem;">Admin</span>
</div>
</div>
</div>
<!-- ─── Main content ─── -->
<div class="main">
<div class="main-inner">
<div id="sandboxBanner" class="alert alert-warning"
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded from
cost calculations.
</div>
<div class="page-header d-flex align-items-center gap-3">
<a href="index.html" class="btn btn-sm btn-secondary">
<i class="fa-solid fa-arrow-left me-1"></i> Back
</a>
<div>
<h1>Send SMS Now</h1>
<p>Compose and send a one-off message to your attendees instantly.</p>
</div>
</div>
<div class="row g-4">
<!-- Compose card -->
<div class="col-12 col-md-7">
<div class="card p-4">
<h2 class="section-title mb-4">Compose</h2>
<label class="form-label text-muted" style="font-size:0.82rem;">Recipients</label>
<select class="form-select mb-3" id="recipients">
<option value="all" data-count="0">Loading attendees…</option>
</select>
<label class="form-label text-muted" style="font-size:0.82rem;">Message</label>
<select class="form-select mb-2" id="templatePicker">
<option value="">— Custom message —</option>
</select>
<textarea id="message" class="form-control mb-1" rows="5" maxlength="160"
placeholder="Type your message…"></textarea>
<small id="charCount" class="text-muted d-block mb-2">0/160 characters</small>
<div class="d-flex flex-wrap gap-1 mb-4">
<span class="text-muted" style="font-size:0.75rem;align-self:center;">Insert:</span>
<button type="button" class="btn btn-sm"
style="font-size:0.75rem;padding:0.15em 0.6em;background:rgba(79,142,247,0.12);color:var(--accent);border:1px solid rgba(79,142,247,0.3);border-radius:6px;"
onclick="insertPlaceholder('{name}')">{name}</button>
<button type="button" class="btn btn-sm"
style="font-size:0.75rem;padding:0.15em 0.6em;background:rgba(79,142,247,0.12);color:var(--accent);border:1px solid rgba(79,142,247,0.3);border-radius:6px;"
onclick="insertPlaceholder('{workshop}')">{workshop}</button>
<button type="button" class="btn btn-sm"
style="font-size:0.75rem;padding:0.15em 0.6em;background:rgba(79,142,247,0.12);color:var(--accent);border:1px solid rgba(79,142,247,0.3);border-radius:6px;"
onclick="insertPlaceholder('{workshop_time}')">{workshop_time}</button>
</div>
<button class="btn btn-primary w-100" id="sendBtn" disabled>
<i class="fa-solid fa-paper-plane me-2"></i>
Send to <span id="recipientCount">0</span> attendees
</button>
<div id="sendResult" class="mt-3" style="display:none;"></div>
</div>
</div>
<!-- Preview card -->
<div class="col-12 col-md-5">
<div class="card p-4">
<h2 class="section-title mb-4">Preview</h2>
<section id="previewSection" style="visibility:hidden;">
<div class="from-them" id="preview"></div>
</section>
<p id="previewPlaceholder" class="text-muted" style="font-size:0.85rem;">Your message preview will appear
here as you type.</p>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous"></script>
<script>
const SEND_MAX = 160;
// GSM-7 basic + extended character set
const GSM7 = new Set('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà€[]{}\\^|~');
const GSM7_EXTENDED = new Set('€[]{}\\^|~');
function smsEncoding(text) {
const isUnicode = [...text].some(c => !GSM7.has(c));
const limit = isUnicode ? 70 : 160;
const length = isUnicode ? text.length : [...text].reduce((n, c) => n + (GSM7_EXTENDED.has(c) ? 2 : 1), 0);
return { length, limit, isUnicode };
}
const msgEl = document.getElementById('message');
const countEl = document.getElementById('charCount');
const previewEl = document.getElementById('preview');
const sectionEl = document.getElementById('previewSection');
const placeholderEl = document.getElementById('previewPlaceholder');
const sendBtn = document.getElementById('sendBtn');
const countSpan = document.getElementById('recipientCount');
const recipientsSel = document.getElementById('recipients');
const resultEl = document.getElementById('sendResult');
// ── Populate recipients from API ───────────────────────────
async function loadRecipients() {
try {
const [attData, wsData] = await Promise.all([
fetch('/api/attendees.php').then(r => r.json()),
fetch('/api/workshops.php').then(r => r.json()),
]);
const total = attData.total ?? 0;
const workshops = wsData.workshops ?? [];
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 ? ' &nbsp;<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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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 () {
try {
const d = await fetch('/api/admin.php').then(r => r.json());
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
} catch { }
}());
</script>
</body>
</html>
+308
View File
@@ -0,0 +1,308 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SMS Templates — IMFestival SMS Dashboard</title>
<link href="assets/vendor/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" rel="stylesheet"
crossorigin="anonymous">
<link href="assets/css/dashboard.css" rel="stylesheet">
<link href="assets/css/chat.css" rel="stylesheet">
</head>
<body>
<!-- ─── Mobile top bar ─── -->
<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>
</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>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>
<!-- ─── Desktop sidebar ─── -->
<div class="sidebar">
<div class="brand">
<span style="margin: 0 auto;">IMFestival SMS Dashboard</span>
</div>
<nav class="d-flex flex-column gap-1 mt-2">
<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 class="mt-auto pt-3" style="border-top:1px solid var(--border);">
<div class="nav-link-custom" style="cursor:default;">
<i class="fa-solid fa-circle-user fa-fw"></i>
<span style="font-size:0.82rem;">Admin</span>
</div>
</div>
</div>
<!-- ─── Main content ─── -->
<div class="main">
<div class="main-inner">
<div id="sandboxBanner" class="alert alert-warning"
style="display:none;font-size:0.85rem;font-weight:600;text-align:center;">
<i class="fa-solid fa-flask me-1"></i> Sandbox mode is active — texts are tagged as a test and excluded from
cost calculations.
</div>
<div class="page-header">
<h1>SMS Templates</h1>
<p>Create message templates using <code>{name}</code>, <code>{workshop}</code> and <code>{workshop_time}</code>
placeholders.</p>
</div>
<div class="card p-4">
<h5 id="formTitle">New Template</h5>
<input id="templateName" class="form-control mb-2" placeholder="Template name">
<input type="hidden" id="editId" value="">
<textarea id="template" class="form-control mb-1" rows="5" maxlength="160"></textarea>
<small id="charCount" class="d-block mb-3"></small>
<div class="d-flex gap-2 mb-4">
<button class="btn btn-primary" id="saveBtn" onclick="saveTemplate()">
<i class="fa-solid fa-floppy-disk me-1"></i> <span id="saveBtnLabel">Save Template</span>
</button>
<button class="btn btn-sm btn-secondary d-none" id="cancelEditBtn" onclick="cancelEdit()">
Cancel
</button>
</div>
<h5>Preview</h5>
<section>
<div class="from-them" id="preview"></div>
</section>
</div>
<!-- Saved templates -->
<div class="card p-4 mt-4">
<div class="d-flex align-items-center justify-content-between mb-3">
<h5 class="mb-0">Saved Templates</h5>
<span class="text-muted" id="templateCount" style="font-size:0.82rem;"></span>
</div>
<div class="d-flex flex-column gap-3" id="templateList">
<p class="text-muted" style="font-size:0.85rem;">Loading…</p>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
crossorigin="anonymous"></script>
<script>
const MAX_CHARS = 160;
// GSM-7 basic + extended character set
const GSM7 = new Set('@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà€[]{}\\^|~');
const GSM7_EXTENDED = new Set('€[]{}\\^|~');
function smsEncoding(text) {
const isUnicode = [...text].some(c => !GSM7.has(c));
const limit = isUnicode ? 70 : 160;
const length = isUnicode ? text.length : [...text].reduce((n, c) => n + (GSM7_EXTENDED.has(c) ? 2 : 1), 0);
return { length, limit, isUnicode };
}
function updateCharCount(text) {
const { length, limit, isUnicode } = smsEncoding(text);
const remaining = limit - length;
const el = document.getElementById('charCount');
el.innerHTML = `${length}/${limit} characters${isUnicode ? ' &nbsp;<span style="color:#f87171;font-size:0.8em;"><i class="fa-solid fa-triangle-exclamation"></i> Unicode encoding — limit reduced to 70</span>' : ''}`;
el.className = `d-block mb-3 ${remaining <= 10 ? (remaining <= 0 ? 'text-danger' : 'text-warning') : 'text-muted'}`;
}
function updatePreview() {
const template = document.getElementById('template').value;
const previewEl = document.getElementById('preview');
const previewSection = previewEl.closest('section');
if (template.trim() === '') {
previewSection.style.visibility = 'hidden';
previewEl.textContent = '';
} else {
previewSection.style.visibility = 'visible';
previewEl.textContent = template
.replaceAll('{name}', 'Jordan')
.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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 () {
try {
const d = await fetch('/api/admin.php').then(r => r.json());
document.getElementById('sandboxBanner').style.display = d.sandbox_mode ? '' : 'none';
} catch { }
}());
</script>
</body>
</html>