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
+11
View File
@@ -0,0 +1,11 @@
### Database Environment Variables
MYSQL_HOST=db
MYSQL_DATABASE=imf_sms
MYSQL_USER=imf_user
MYSQL_PASSWORD=password
MYSQL_ROOT_PASSWORD=password123
TZ=Europe/London
### SMS Worker
WORKER_URL=http://worker:5000
+2
View File
@@ -0,0 +1,2 @@
FROM php:8.3-apache
RUN docker-php-ext-install pdo pdo_mysql
+18
View File
@@ -0,0 +1,18 @@
<center><h1>IMFestival SMS Dashboard</h1></center>
### What is this repository?
This repository holds the SMS notification system for IMFestival, featuring a web dashboard along with containerization files (`Dockerfile` and `docker-compose.yml`) for easy deployment.
## How to use the dashboard
The dashboard will allow the upload of a CSV file that contains the user's name, email, workshop name and times.
You can use the dashboard to schedule messages for delivery to selected users at specific times. Message content can be configured directly within the dashboard and supports the placeholders {name} for the recipient's name, {workshop} for the workshop name, and {workshop_time} for the workshop time.
## Considerations
The character limit needs to check against the longest workshop name to ensure the limit isn't misrespresented.
### Python Dependencies
- numpy
- dateutil
- pandas
+115
View File
@@ -0,0 +1,115 @@
-- IMFestival SMS Dashboard — database schema
-- Runs automatically on first MariaDB container start.
CREATE DATABASE IF NOT EXISTS imf_sms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE imf_sms;
-- ─── Core attendee data ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS attendees (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
email VARCHAR(255) DEFAULT NULL,
mobile_number VARCHAR(30) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_mobile (mobile_number)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS workshop_sessions (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
workshop_name VARCHAR(255) NOT NULL,
workshop_time DATETIME DEFAULT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS registrations (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
attendee_id INT UNSIGNED NOT NULL,
workshop_session_id INT UNSIGNED NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (attendee_id) REFERENCES attendees(id) ON DELETE CASCADE,
FOREIGN KEY (workshop_session_id) REFERENCES workshop_sessions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── SMS templates ────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS sms_templates (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
body VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Scheduled send rules ─────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS scheduled_rules (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
template_id INT UNSIGNED NOT NULL,
mode ENUM('relative','specific') NOT NULL,
offset_value INT DEFAULT NULL, -- used when mode=relative
offset_unit ENUM('minutes','hours','days') DEFAULT NULL, -- used when mode=relative
specific_datetime DATETIME DEFAULT NULL, -- used when mode=specific
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
FOREIGN KEY (template_id) REFERENCES sms_templates(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Send jobs ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS send_jobs (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
template_id INT UNSIGNED DEFAULT NULL,
custom_body VARCHAR(160) DEFAULT NULL,
recipient_scope ENUM('all','workshop') NOT NULL DEFAULT 'all',
workshop_session_id INT UNSIGNED DEFAULT NULL,
scheduled_rule_id INT UNSIGNED DEFAULT NULL,
status ENUM('pending','sending','completed','failed') NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
sent_at TIMESTAMP DEFAULT NULL,
PRIMARY KEY (id),
FOREIGN KEY (template_id) REFERENCES sms_templates(id) ON DELETE SET NULL,
FOREIGN KEY (workshop_session_id) REFERENCES workshop_sessions(id) ON DELETE SET NULL,
FOREIGN KEY (scheduled_rule_id) REFERENCES scheduled_rules(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── Delivery log ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS delivery_log (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
send_job_id INT UNSIGNED NOT NULL,
attendee_id INT UNSIGNED DEFAULT NULL,
mobile_number VARCHAR(30) NOT NULL,
status ENUM('sent','delivered','failed') NOT NULL DEFAULT 'sent',
sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
delivered_at TIMESTAMP DEFAULT NULL,
error_message TEXT DEFAULT NULL,
PRIMARY KEY (id),
FOREIGN KEY (send_job_id) REFERENCES send_jobs(id) ON DELETE CASCADE,
FOREIGN KEY (attendee_id) REFERENCES attendees(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ─── System settings ──────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS `system` (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
setting_name VARCHAR(100) NOT NULL,
setting_value TEXT DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_setting_name (setting_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Seed default settings
INSERT IGNORE INTO `system` (setting_name, setting_value) VALUES
('clickatell_api_key', NULL),
('clickatell_cost_per_sms', '0.04849'),
('default_sender_id', 'IMFestival'),
('sandbox_mode', '0');
-- ─── Schema migrations (idempotent) ─────────────────────────────────────────
-- Safe to run on existing databases; ADD COLUMN IF NOT EXISTS is a no-op when
-- the column already exists.
ALTER TABLE delivery_log ADD COLUMN IF NOT EXISTS is_sandbox TINYINT(1) NOT NULL DEFAULT 0;
+40
View File
@@ -0,0 +1,40 @@
services:
db:
image: mariadb:11
volumes:
- imf_db:/var/lib/mysql
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "3306:3306"
env_file:
- .env
environment:
TZ: "${TZ:-Europe/London}"
web:
build: .
volumes:
- ./web:/var/www/html
ports:
- "8000:80"
env_file:
- .env
environment:
TZ: "${TZ:-Europe/London}"
depends_on:
- db
worker:
build:
context: ./python
dockerfile: Dockerfile
env_file:
- .env
environment:
TZ: "${TZ:-Europe/London}"
depends_on:
- db
restart: unless-stopped
volumes:
imf_db:
+6
View File
@@ -0,0 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
CMD ["python", "main.py"]
+396
View File
@@ -0,0 +1,396 @@
#!/usr/bin/env python3
"""
IMFestival SMS Worker
---------------------
Runs as a Flask HTTP service on port 5000.
POST /send { "job_id": N }
Sends all messages for an existing send_jobs row and writes delivery_log entries.
Called by the PHP API immediately after a send_job is created.
GET /health
Returns {"status": "ok"} — used by Docker health checks.
Background scheduler thread
Wakes every 60 s, resolves due scheduled_rules, creates send_job rows, then
calls the same send logic used by /send.
"""
import os
import time
import logging
from datetime import datetime, timedelta
from threading import Thread
import requests as http_requests
import mysql.connector
from flask import Flask, request, jsonify
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
log = logging.getLogger(__name__)
app = Flask(__name__)
CLICKATELL_URL = "https://platform.clickatell.com/messages"
DB_CONFIG = {
"host": os.environ.get("MYSQL_HOST", "db"),
"user": os.environ.get("MYSQL_USER", "imf_user"),
"password": os.environ.get("MYSQL_PASSWORD", "password"),
"database": os.environ.get("MYSQL_DATABASE", "imf_sms"),
}
# -- Database helpers ---------------------------------------------------------
def get_db():
return mysql.connector.connect(**DB_CONFIG)
def get_api_key(cursor) -> str | None:
cursor.execute(
"SELECT setting_value FROM `system` WHERE setting_name = 'clickatell_api_key'"
)
row = cursor.fetchone()
return row["setting_value"] if row and row["setting_value"] else None
# -- SMS character handling --------------------------------------------------
# Characters that ARE in the GSM-7 basic + extended character set.
# Anything outside this set forces UCS-2 (Unicode) encoding → 70 chars/part.
_GSM7 = set(
"@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞ\x1b"
"ÆæßÉ !\"%&'()*+,-./0123456789:;<=>?"
"¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ"
"¿abcdefghijklmnopqrstuvwxyzäöñüà"
# Extended (each costs 2 GSM-7 chars)
"€[]{}\\^|~"
)
_NORMALISE_MAP = str.maketrans({
"\u2018": "'", # left single quotation mark
"\u2019": "'", # right single quotation mark / apostrophe
"\u201c": '"', # left double quotation mark
"\u201d": '"', # right double quotation mark
"\u2013": "-", # en dash
"\u2014": "-", # em dash
"\u2026": "...", # horizontal ellipsis
"\u00a0": " ", # non-breaking space
"\u2022": "*", # bullet
})
def normalise_gsm7(text: str) -> str:
"""Replace common non-GSM-7 characters with safe ASCII equivalents."""
return text.translate(_NORMALISE_MAP)
def sms_length(text: str) -> tuple[int, bool]:
"""
Returns (effective_length, is_unicode).
Extended GSM-7 characters (€[]{}\\^|~) count as 2 chars each.
If any character is outside GSM-7, the whole message is Unicode (UCS-2).
"""
if any(c not in _GSM7 for c in text):
return len(text), True
extended = set("€[]{}\\^|~")
return sum(2 if c in extended else 1 for c in text), False
# -- Clickatell dispatch ------------------------------------------------------
def clickatell_send(api_key: str, mobile: str, message: str) -> tuple[bool, str]:
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": api_key,
}
try:
r = http_requests.post(
CLICKATELL_URL,
json={"content": message, "to": [mobile]},
headers=headers,
timeout=15,
)
return r.status_code == 202, r.text
except Exception as exc:
return False, str(exc)
# -- Core send logic ----------------------------------------------------------
def execute_job(job_id: int) -> dict:
"""
Send messages for the given send_jobs row, write delivery_log entries, and
update the job status. Returns {"sent": N, "failed": N} or {"error": "..."}.
"""
conn = get_db()
cursor = conn.cursor(dictionary=True)
try:
cursor.execute(
"SELECT sj.id, sj.recipient_scope, sj.workshop_session_id, "
" COALESCE(sj.custom_body, t.body) AS message_body "
"FROM send_jobs sj "
"LEFT JOIN sms_templates t ON t.id = sj.template_id "
"WHERE sj.id = %s",
(job_id,),
)
job = cursor.fetchone()
if not job:
return {"error": f"Job {job_id} not found"}
api_key = get_api_key(cursor)
if not api_key:
cursor.execute(
"UPDATE send_jobs SET status='failed' WHERE id = %s", (job_id,)
)
conn.commit()
return {"error": "Clickatell API key not configured"}
cursor.execute(
"SELECT setting_value FROM `system` WHERE setting_name = 'sandbox_mode'"
)
sandbox_row = cursor.fetchone()
is_sandbox = 1 if sandbox_row and sandbox_row["setting_value"] == "1" else 0
if is_sandbox:
log.info("Job %d: sandbox mode ON — sends will be tagged as sandbox", job_id)
if job["recipient_scope"] == "all":
cursor.execute(
"SELECT a.id, a.first_name, a.mobile_number, "
" (SELECT ws2.workshop_name FROM registrations r2 "
" JOIN workshop_sessions ws2 ON ws2.id = r2.workshop_session_id "
" WHERE r2.attendee_id = a.id ORDER BY ws2.workshop_time LIMIT 1) AS workshop_name, "
" (SELECT ws2.workshop_time FROM registrations r2 "
" JOIN workshop_sessions ws2 ON ws2.id = r2.workshop_session_id "
" WHERE r2.attendee_id = a.id ORDER BY ws2.workshop_time LIMIT 1) AS workshop_time "
"FROM attendees a"
)
else:
cursor.execute(
"SELECT a.id, a.first_name, a.mobile_number, "
" ws.workshop_name, ws.workshop_time "
"FROM attendees a "
"JOIN registrations r ON r.attendee_id = a.id "
"JOIN workshop_sessions ws ON ws.id = r.workshop_session_id "
"WHERE r.workshop_session_id = %s",
(job["workshop_session_id"],),
)
recipients = cursor.fetchall()
if not recipients:
cursor.execute(
"UPDATE send_jobs SET status='completed', sent_at=NOW() WHERE id = %s",
(job_id,),
)
conn.commit()
return {"sent": 0, "failed": 0}
cursor.execute(
"UPDATE send_jobs SET status='sending', sent_at=NOW() WHERE id = %s",
(job_id,),
)
conn.commit()
sent = failed = 0
message = job["message_body"] or ""
for recipient in recipients:
ws_name = recipient.get("workshop_name") or ""
ws_time_raw = recipient.get("workshop_time")
if ws_time_raw and hasattr(ws_time_raw, "strftime"):
ws_time = ws_time_raw.strftime("%H:%M")
elif ws_time_raw:
ws_time = str(ws_time_raw)[11:16] # extract HH:MM from datetime string
else:
ws_time = ""
msg = (
message
.replace("{name}", recipient["first_name"] or "")
.replace("{first name}", recipient["first_name"] or "")
.replace("{first_name}", recipient["first_name"] or "")
.replace("{workshop}", ws_name)
.replace("{workshop_time}", ws_time)
)
msg = normalise_gsm7(msg)
length, is_unicode = sms_length(msg)
limit = 70 if is_unicode else 160
if length > limit:
log.warning(
"Message to %s is %d chars (%s encoding, limit %d) — truncating",
recipient["mobile_number"], length,
"Unicode" if is_unicode else "GSM-7", limit,
)
msg = msg[:limit]
success, response_text = clickatell_send(api_key, recipient["mobile_number"], msg)
status = "sent" if success else "failed"
error = None if success else response_text[:500]
cursor.execute(
"INSERT INTO delivery_log "
" (send_job_id, attendee_id, mobile_number, status, sent_at, error_message, is_sandbox) "
"VALUES (%s, %s, %s, %s, NOW(), %s, %s)",
(job_id, recipient["id"], recipient["mobile_number"], status, error, is_sandbox),
)
conn.commit()
if success:
sent += 1
log.info("Sent -> %s", recipient["mobile_number"])
else:
failed += 1
log.warning("Failed -> %s : %s", recipient["mobile_number"], response_text[:120])
final_status = "completed" if sent > 0 else "failed"
cursor.execute(
"UPDATE send_jobs SET status=%s, sent_at=NOW() WHERE id = %s",
(final_status, job_id),
)
conn.commit()
log.info("Job %d complete -- sent=%d failed=%d", job_id, sent, failed)
return {"sent": sent, "failed": failed}
except Exception as exc:
log.exception("execute_job error for job %d", job_id)
try:
cursor.execute(
"UPDATE send_jobs SET status='failed' WHERE id = %s", (job_id,)
)
conn.commit()
except Exception:
pass
return {"error": str(exc)}
finally:
cursor.close()
conn.close()
# -- HTTP endpoints -----------------------------------------------------------
@app.route("/send", methods=["POST"])
def send_endpoint():
data = request.get_json(force=True) or {}
job_id = data.get("job_id")
if not job_id:
return jsonify({"error": "job_id required"}), 400
result = execute_job(int(job_id))
if "error" in result:
return jsonify(result), 500
return jsonify(result)
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok"})
# -- Scheduler ----------------------------------------------------------------
def run_scheduler():
"""Check for due scheduled_rules and fire any that have not run yet."""
conn = get_db()
cursor = conn.cursor(dictionary=True)
try:
now = datetime.now()
cursor.execute(
"SELECT id, template_id, mode, offset_value, offset_unit, specific_datetime "
"FROM scheduled_rules"
)
rules = cursor.fetchall()
for rule in rules:
if rule["mode"] == "specific":
fire_time = rule["specific_datetime"]
if isinstance(fire_time, str):
fire_time = datetime.fromisoformat(fire_time)
if now < fire_time:
log.info("Rule %d (specific): not yet due — fires at %s", rule["id"], fire_time)
continue
# Already executed?
cursor.execute(
"SELECT id FROM send_jobs "
"WHERE scheduled_rule_id = %s AND workshop_session_id IS NULL LIMIT 1",
(rule["id"],),
)
if cursor.fetchone():
continue
cursor.execute(
"INSERT INTO send_jobs "
" (template_id, recipient_scope, scheduled_rule_id, status) "
"VALUES (%s, 'all', %s, 'pending')",
(rule["template_id"], rule["id"]),
)
job_id = cursor.lastrowid
conn.commit()
log.info("Firing specific rule %d -> job %d", rule["id"], job_id)
execute_job(job_id)
elif rule["mode"] == "relative":
unit_map = {"minutes": "minutes", "hours": "hours", "days": "days"}
offset = timedelta(
**{unit_map.get(rule["offset_unit"], "minutes"): rule["offset_value"]}
)
# Apply rule to every workshop session individually
cursor.execute("SELECT id, workshop_time FROM workshop_sessions")
for session in cursor.fetchall():
ws_time = session["workshop_time"]
if isinstance(ws_time, str):
ws_time = datetime.fromisoformat(ws_time)
fire_time = ws_time - offset
if now < fire_time:
log.debug("Rule %d session %d: not yet due — fires at %s", rule["id"], session["id"], fire_time)
continue
# Already executed for this (rule, session) pair?
cursor.execute(
"SELECT id FROM send_jobs "
"WHERE scheduled_rule_id = %s AND workshop_session_id = %s LIMIT 1",
(rule["id"], session["id"]),
)
if cursor.fetchone():
continue
cursor.execute(
"INSERT INTO send_jobs "
" (template_id, recipient_scope, workshop_session_id, "
" scheduled_rule_id, status) "
"VALUES (%s, 'workshop', %s, %s, 'pending')",
(rule["template_id"], session["id"], rule["id"]),
)
job_id = cursor.lastrowid
conn.commit()
log.info(
"Firing relative rule %d for session %d -> job %d",
rule["id"], session["id"], job_id,
)
execute_job(job_id)
except Exception as exc:
log.exception("Scheduler error: %s", exc)
finally:
cursor.close()
conn.close()
def scheduler_loop():
log.info("Scheduler started -- polling every 60 s")
# Brief startup delay to let MariaDB finish initialising
time.sleep(5)
while True:
log.info("Scheduler tick")
run_scheduler()
time.sleep(60)
# -- Entry point --------------------------------------------------------------
if __name__ == "__main__":
Thread(target=scheduler_loop, daemon=True).start()
app.run(host="0.0.0.0", port=5000, debug=False)
+3
View File
@@ -0,0 +1,3 @@
flask>=3.0
requests>=2.31
mysql-connector-python>=8.0
+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>