diff --git a/website/css/style.css b/website/css/style.css
index bb9ebe9..4e4d9e8 100644
--- a/website/css/style.css
+++ b/website/css/style.css
@@ -1900,6 +1900,23 @@ body.modal-open {
border: 1px solid var(--border);
}
+.confirm-modal-message {
+ margin: 0 0 16px;
+ line-height: 1.5;
+}
+.confirm-modal-message p {
+ margin: 0 0 10px;
+}
+.confirm-modal-message p:last-child {
+ margin-bottom: 0;
+}
+.confirm-modal-choices {
+ margin-bottom: 16px;
+}
+.confirm-modal--danger .confirm-modal-dialog {
+ border-color: color-mix(in srgb, var(--danger-border) 40%, var(--border));
+}
+
/* Options builder (inside add-question form) */
.options-builder {
border-top: 1px solid var(--border);
diff --git a/website/js/client-archive.js b/website/js/client-archive.js
index 97b90de..35bbd66 100644
--- a/website/js/client-archive.js
+++ b/website/js/client-archive.js
@@ -1,15 +1,20 @@
import { apiPatch } from './api.js';
import { showToast } from './app.js';
+import { confirmAction } from './confirm-modal.js';
export function isClientArchived(c) {
return Number(c.archived) === 1;
}
export async function confirmAndPatchClientArchive(clientCode, archived) {
- const msg = archived
- ? `Archive client "${clientCode}"? They will be hidden from assignments, follow-up, and the mobile app. You can restore them later from the Clients page.`
- : `Restore client "${clientCode}" to active lists?`;
- if (!confirm(msg)) return false;
+ if (!(await confirmAction({
+ title: archived ? 'Archive client' : 'Restore client',
+ message: archived
+ ? `Archive client "${clientCode}"? They will be hidden from assignments, follow-up, and the mobile app. You can restore them later from the Clients page.`
+ : `Restore client "${clientCode}" to active lists?`,
+ confirmLabel: archived ? 'Archive' : 'Restore',
+ variant: archived ? 'danger' : 'default',
+ }))) return false;
try {
await apiPatch('clients.php', { clientCode, archived });
diff --git a/website/js/confirm-modal.js b/website/js/confirm-modal.js
new file mode 100644
index 0000000..cac0ad2
--- /dev/null
+++ b/website/js/confirm-modal.js
@@ -0,0 +1,148 @@
+function esc(s) {
+ const d = document.createElement('div');
+ d.textContent = s ?? '';
+ return d.innerHTML;
+}
+
+function formatMessageHTML(message) {
+ return String(message)
+ .split(/\n\n+/)
+ .map((block) => `
${esc(block).replace(/\n/g, '
')}
`)
+ .join('');
+}
+
+function renderChoicesHTML(choices) {
+ const name = 'confirm_modal_choice';
+ return `
+ `;
+}
+
+/** @type {((value: boolean | string | null) => void) | null} */
+let resolveConfirm = null;
+
+function ensureConfirmModal() {
+ let modal = document.getElementById('confirmModal');
+ if (modal) return modal;
+
+ modal = document.createElement('div');
+ modal.id = 'confirmModal';
+ modal.className = 'modal-overlay';
+ modal.hidden = true;
+ modal.setAttribute('role', 'dialog');
+ modal.setAttribute('aria-modal', 'true');
+ modal.setAttribute('aria-labelledby', 'confirmModalTitle');
+ modal.innerHTML = `
+
+
+
+
+
+
+
+
+
+ `;
+ document.body.appendChild(modal);
+
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) finishConfirm(null);
+ });
+ modal.querySelector('[data-confirm-cancel]').addEventListener('click', () => finishConfirm(null));
+ modal.querySelector('[data-confirm-submit]').addEventListener('click', () => {
+ const choicesEl = modal.querySelector('#confirmModalChoices');
+ const hasChoices = choicesEl.childElementCount > 0;
+ if (hasChoices) {
+ const selected = modal.querySelector('input[name="confirm_modal_choice"]:checked');
+ finishConfirm(selected ? selected.value : null);
+ } else {
+ finishConfirm(true);
+ }
+ });
+
+ document.addEventListener('keydown', (e) => {
+ const el = document.getElementById('confirmModal');
+ if (!el || el.hidden) return;
+ if (e.key === 'Escape') finishConfirm(null);
+ });
+
+ return modal;
+}
+
+function finishConfirm(value) {
+ const modal = document.getElementById('confirmModal');
+ if (!modal || modal.hidden) return;
+ modal.hidden = true;
+ document.body.classList.remove('modal-open');
+ const resolve = resolveConfirm;
+ resolveConfirm = null;
+ if (resolve) resolve(value);
+}
+
+/**
+ * Show a confirmation dialog.
+ *
+ * Without `choices`: resolves to `true` (confirmed) or `false` (cancelled).
+ * With `choices`: resolves to the selected choice value (string) or `null` (cancelled).
+ *
+ * @param {Object} options
+ * @param {string} options.title
+ * @param {string} options.message
+ * @param {string} [options.confirmLabel='Confirm']
+ * @param {string} [options.cancelLabel='Cancel']
+ * @param {'default'|'danger'} [options.variant='default']
+ * @param {{ value: string, label: string, description?: string }[]} [options.choices]
+ * @returns {Promise}
+ */
+export function confirmAction(options) {
+ return new Promise((resolve) => {
+ if (resolveConfirm) {
+ resolve(false);
+ return;
+ }
+
+ const modal = ensureConfirmModal();
+ const hasChoices = Array.isArray(options.choices) && options.choices.length > 0;
+ const variant = options.variant === 'danger' ? 'danger' : 'default';
+
+ modal.querySelector('#confirmModalTitle').textContent = options.title || 'Confirm';
+ modal.querySelector('#confirmModalMessage').innerHTML = formatMessageHTML(options.message || '');
+
+ const choicesEl = modal.querySelector('#confirmModalChoices');
+ choicesEl.innerHTML = hasChoices ? renderChoicesHTML(options.choices) : '';
+
+ const cancelBtn = modal.querySelector('[data-confirm-cancel]');
+ const submitBtn = modal.querySelector('[data-confirm-submit]');
+ cancelBtn.textContent = options.cancelLabel || 'Cancel';
+ submitBtn.textContent = options.confirmLabel || (hasChoices ? 'Continue' : 'Confirm');
+ submitBtn.className = `btn btn-sm ${variant === 'danger' ? 'btn-danger' : 'btn-primary'}`;
+
+ modal.classList.toggle('confirm-modal--danger', variant === 'danger');
+
+ resolveConfirm = (value) => {
+ if (hasChoices) {
+ resolve(value);
+ } else {
+ resolve(value === true);
+ }
+ };
+
+ modal.hidden = false;
+ document.body.classList.add('modal-open');
+ (hasChoices
+ ? modal.querySelector('input[name="confirm_modal_choice"]')
+ : submitBtn
+ )?.focus();
+ });
+}
diff --git a/website/js/pages/assignments.js b/website/js/pages/assignments.js
index b7c3a96..9abe17b 100644
--- a/website/js/pages/assignments.js
+++ b/website/js/pages/assignments.js
@@ -1,5 +1,6 @@
import { apiGet, apiPost } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js';
+import { confirmAction } from '../confirm-modal.js';
const CLIENT_PAGE_SIZE = 50;
@@ -421,7 +422,11 @@ async function doAssign() {
if (!coachID) { showToast('Select a target counselor', 'error'); return; }
const coachName = coaches.find(c => c.coachID === coachID)?.username || coachID;
- if (!confirm(`Assign ${selected.length} client(s) to ${coachName}?`)) return;
+ if (!(await confirmAction({
+ title: 'Assign clients',
+ message: `Assign ${selected.length} client(s) to ${coachName}?`,
+ confirmLabel: 'Assign',
+ }))) return;
const btn = document.getElementById('assignBtn');
btn.disabled = true;
diff --git a/website/js/pages/clients.js b/website/js/pages/clients.js
index 32887db..2eb0173 100644
--- a/website/js/pages/clients.js
+++ b/website/js/pages/clients.js
@@ -1,5 +1,6 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { pageHeaderHTML, showToast } from '../app.js';
+import { confirmAction } from '../confirm-modal.js';
import {
isClientArchived,
confirmAndPatchClientArchive,
@@ -818,7 +819,12 @@ async function setClientArchived(clientCode, archived) {
}
async function deleteClient(clientCode) {
- if (!confirm(`Delete client "${clientCode}"? This will also remove all their answers and results. This cannot be undone.`)) return;
+ if (!(await confirmAction({
+ title: 'Delete client',
+ message: `Delete client "${clientCode}"? This will also remove all their answers and results. This cannot be undone.`,
+ confirmLabel: 'Delete',
+ variant: 'danger',
+ }))) return;
try {
await apiDelete('clients.php', { clientCode });
clientsList = clientsList.filter(c => c.clientCode !== clientCode);
diff --git a/website/js/pages/dev.js b/website/js/pages/dev.js
index 639a8c0..25a8696 100644
--- a/website/js/pages/dev.js
+++ b/website/js/pages/dev.js
@@ -1,5 +1,6 @@
import { apiGet, apiPost, apiPut, apiDownloadFetch, redirectToLogin } from '../api.js';
import { getRole, pageHeaderHTML, showToast } from '../app.js';
+import { confirmAction } from '../confirm-modal.js';
import { navigate } from '../router.js';
const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS';
@@ -166,11 +167,12 @@ export function devPage() {
});
document.getElementById('devRemoveBtn').addEventListener('click', async () => {
- if (!confirm(
- 'Remove all dev test data?\n\n'
- + 'Deletes users matching dev_*, clients DEV-CL-*, and their answers/completions.\n'
- + 'Real accounts are not affected.'
- )) {
+ if (!(await confirmAction({
+ title: 'Remove dev test data',
+ message: 'Remove all dev test data?\n\nDeletes users matching dev_*, clients DEV-CL-*, and their answers/completions.\nReal accounts are not affected.',
+ confirmLabel: 'Remove',
+ variant: 'danger',
+ }))) {
return;
}
const btn = document.getElementById('devRemoveBtn');
@@ -206,11 +208,12 @@ export function devPage() {
});
document.getElementById('devWipeBtn').addEventListener('click', async () => {
- if (!confirm(
- 'Remove ALL clients, completions, counselors, and supervisors?\n\n'
- + 'Admin accounts and questionnaires will NOT be deleted.\n'
- + 'This cannot be undone.'
- )) {
+ if (!(await confirmAction({
+ title: 'Wipe operational data',
+ message: 'Remove ALL clients, completions, counselors, and supervisors?\n\nAdmin accounts and questionnaires will NOT be deleted.\nThis cannot be undone.',
+ confirmLabel: 'Remove all',
+ variant: 'danger',
+ }))) {
return;
}
const btn = document.getElementById('devWipeBtn');
@@ -533,10 +536,12 @@ function wireSecuritySettings() {
revokeBtn?.addEventListener('click', async () => {
if (revokeInput.value.trim() !== REVOKE_ALL_CONFIRM) return;
- if (!confirm(
- 'Revoke every active session?\n\n'
- + 'All users (including you) will be signed out on website and mobile.'
- )) {
+ if (!(await confirmAction({
+ title: 'Revoke all sessions',
+ message: 'Revoke every active session?\n\nAll users (including you) will be signed out on website and mobile.',
+ confirmLabel: 'Revoke all',
+ variant: 'danger',
+ }))) {
return;
}
revokeBtn.disabled = true;
diff --git a/website/js/pages/editor.js b/website/js/pages/editor.js
index 3a9b349..cdf349d 100644
--- a/website/js/pages/editor.js
+++ b/website/js/pages/editor.js
@@ -1,5 +1,6 @@
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from '../api.js';
import { canEdit, homeNavButton, showToast } from '../app.js';
+import { confirmAction } from '../confirm-modal.js';
import { navigate } from '../router.js';
import {
SOURCE_LANG,
@@ -1668,7 +1669,12 @@ async function deleteQuestion(q) {
return;
}
const msg = 'Remove this question? If client data exists it will be retired (not deleted) so upload history stays intact.';
- if (!confirm(msg)) return;
+ if (!(await confirmAction({
+ title: 'Remove question',
+ message: msg,
+ confirmLabel: 'Remove',
+ variant: 'danger',
+ }))) return;
try {
const data = await apiDelete('questions.php', { questionID: q.questionID });
questions = questions.filter(x => x.questionID !== q.questionID);
@@ -1702,7 +1708,12 @@ async function updateOption(q, answerOptionID, fields) {
}
async function deleteOption(q, answerOptionID) {
- if (!confirm('Remove this answer option?')) return;
+ if (!(await confirmAction({
+ title: 'Remove answer option',
+ message: 'Remove this answer option?',
+ confirmLabel: 'Remove',
+ variant: 'danger',
+ }))) return;
try {
const data = await apiDelete('answer_options.php', { answerOptionID });
if (!data.retired) {
@@ -1976,7 +1987,12 @@ function bindLanguageManager(languages) {
document.querySelectorAll('.lang-chip-remove').forEach(btn => {
btn.addEventListener('click', async () => {
const lc = btn.dataset.lc;
- if (!confirm(`Remove language "${lc}" and ALL its translations?`)) return;
+ if (!(await confirmAction({
+ title: 'Remove language',
+ message: `Remove language "${lc}" and ALL its translations?`,
+ confirmLabel: 'Remove',
+ variant: 'danger',
+ }))) return;
try {
await apiDelete('translations.php', { type: 'language', languageCode: lc });
showToast(`Language "${lc}" removed`, 'success');
diff --git a/website/js/pages/questionnaires.js b/website/js/pages/questionnaires.js
index 2ffbfe9..251f939 100644
--- a/website/js/pages/questionnaires.js
+++ b/website/js/pages/questionnaires.js
@@ -1,5 +1,6 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { canEdit, pageHeaderHTML, showToast, getRole } from '../app.js';
+import { confirmAction } from '../confirm-modal.js';
import { navigate } from '../router.js';
export async function questionnairesPage() {
@@ -111,7 +112,12 @@ function renderCategoryKeysPanel(categoryKeys) {
if (count > 0) {
msg += `\n\n${count} questionnaire(s) will have their category key cleared.`;
}
- if (!confirm(msg)) return;
+ if (!(await confirmAction({
+ title: 'Delete category key',
+ message: msg,
+ confirmLabel: 'Delete',
+ variant: 'danger',
+ }))) return;
btn.disabled = true;
try {
const result = await apiPost('questionnaires.php', { action: 'deleteCategoryKey', categoryKey: key });
@@ -297,7 +303,12 @@ function renderGrid(questionnaires, categoryKeys = []) {
grid.querySelectorAll('.delete-q-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
- if (!confirm('Delete this questionnaire and all its data?')) return;
+ if (!(await confirmAction({
+ title: 'Delete questionnaire',
+ message: 'Delete this questionnaire and all its data?',
+ confirmLabel: 'Delete',
+ variant: 'danger',
+ }))) return;
try {
await apiDelete('questionnaires.php', { questionnaireID: btn.dataset.id });
showToast('Questionnaire deleted', 'success');
@@ -491,7 +502,12 @@ function renderScoringProfilesSection(profiles, questionnaires) {
});
list.querySelectorAll('.delete-profile-btn').forEach(btn => {
btn.addEventListener('click', async () => {
- if (!confirm(`Delete scoring profile "${btn.dataset.name}"?`)) return;
+ if (!(await confirmAction({
+ title: 'Delete scoring profile',
+ message: `Delete scoring profile "${btn.dataset.name}"?`,
+ confirmLabel: 'Delete',
+ variant: 'danger',
+ }))) return;
try {
await apiDelete('scoring_profiles.php', { profileID: btn.dataset.id });
showToast('Profile deleted', 'success');
@@ -761,12 +777,32 @@ function bindImportBundle() {
}
const count = bundle.questionnaires.length;
- if (!confirm(`Import ${count} questionnaire(s) from this file?`)) return;
- const replace = confirm(
- 'If a questionnaire ID from the file already exists:\n\n' +
- 'OK — replace that questionnaire\n' +
- 'Cancel — import as a new copy (new ID)'
- );
+ if (!(await confirmAction({
+ title: 'Import questionnaires',
+ message: `Import ${count} questionnaire(s) from this file?`,
+ confirmLabel: 'Import',
+ }))) return;
+
+ const mode = await confirmAction({
+ title: 'Existing questionnaire IDs',
+ message: 'If a questionnaire ID from the file already exists, how should it be handled?',
+ choices: [
+ {
+ value: 'replace',
+ label: 'Replace existing',
+ description: 'Overwrite the questionnaire with that ID.',
+ },
+ {
+ value: 'copy',
+ label: 'Import as new copy',
+ description: 'Keep the existing questionnaire and import with a new ID.',
+ },
+ ],
+ confirmLabel: 'Continue',
+ cancelLabel: 'Cancel import',
+ });
+ if (!mode) return;
+ const replace = mode === 'replace';
btn.disabled = true;
const prev = btn.textContent;
diff --git a/website/js/pages/translations.js b/website/js/pages/translations.js
index 5714d5d..61a6ec6 100644
--- a/website/js/pages/translations.js
+++ b/website/js/pages/translations.js
@@ -1,5 +1,6 @@
import { apiGet, apiPost, apiPut, apiDelete, apiDownloadFetch } from '../api.js';
import { canEdit, pageHeaderHTML, showToast } from '../app.js';
+import { confirmAction } from '../confirm-modal.js';
import {
SOURCE_LANG,
normalizeEntry,
@@ -308,7 +309,12 @@ function renderLanguagePanel(languages) {
panel.querySelectorAll('.lang-chip-remove').forEach(btn => {
btn.addEventListener('click', async () => {
const lc = btn.dataset.lc;
- if (!confirm(`Remove "${lc}" and all its translations?`)) return;
+ if (!(await confirmAction({
+ title: 'Remove language',
+ message: `Remove "${lc}" and all its translations?`,
+ confirmLabel: 'Remove',
+ variant: 'danger',
+ }))) return;
try {
await apiDelete('translations.php', { type: 'language', languageCode: lc });
showToast(`Language "${lc}" removed`, 'success');
@@ -671,7 +677,11 @@ function bindTranslationBundleActions() {
}
const count = bundle.entries.length;
- if (!confirm(`Import translations for ${count} entries from this file?`)) return;
+ if (!(await confirmAction({
+ title: 'Import translations',
+ message: `Import translations for ${count} entries from this file?`,
+ confirmLabel: 'Import',
+ }))) return;
importBtn.disabled = true;
const prev = importBtn.textContent;
diff --git a/website/js/pages/users.js b/website/js/pages/users.js
index 44d16db..449c78a 100644
--- a/website/js/pages/users.js
+++ b/website/js/pages/users.js
@@ -1,5 +1,6 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { getRole, getUser, pageHeaderHTML, showToast } from '../app.js';
+import { confirmAction } from '../confirm-modal.js';
import { openAdminResetPasswordModal } from '../password-modal.js';
// Roles an admin can create; supervisors can only create coaches
@@ -319,10 +320,12 @@ async function reassignCoachSupervisor(selectEl) {
}
async function revokeUserSessions(userID, username) {
- if (!confirm(
- `Sign out "${username}" on all devices?\n\n`
- + 'They must log in again on the website and mobile app.'
- )) {
+ if (!(await confirmAction({
+ title: 'Sign out everywhere',
+ message: `Sign out "${username}" on all devices?\n\nThey must log in again on the website and mobile app.`,
+ confirmLabel: 'Sign out',
+ variant: 'danger',
+ }))) {
return;
}
try {
@@ -336,7 +339,12 @@ async function revokeUserSessions(userID, username) {
}
async function deleteUser(userID, username) {
- if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
+ if (!(await confirmAction({
+ title: 'Delete user',
+ message: `Delete user "${username}"? This cannot be undone.`,
+ confirmLabel: 'Delete',
+ variant: 'danger',
+ }))) return;
try {
const data = await apiDelete('users.php', { userID });
if (data.success) {