Add SMS gating, TCPA terms agreement, and compose-before-send modal
- Three-tier SMS gate: platform kill-switch → admin force-disable → plan AllowSms → company opt-in - CompanySmsAgreement entity records admin acceptance of TCPA terms with IP, user agent, and terms version - SMS terms of service modal on Company Settings with versioned re-agreement (AppConstants.SmsTermsVersion) - Dev redirect: non-production SMS routed to Twilio:DevRedirectPhone to protect real customer numbers - Removed redundant Ready for Pickup SMS (Job Completed covers it) - Role-based compose modal on job completion: Admin/Manager reviews and edits before send; ShopFloor auto-sends - Send SMS button on job details for ad-hoc messages (Admin/Manager only) - SendJobSmsAsync auto-appends STOP opt-out language if missing - Migrations: AddSmsGating, AddCompanySmsAgreement Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* SMS compose modal for Job Details — Admin/Manager path.
|
||||
*
|
||||
* Entry points:
|
||||
* - Auto-opens after CompleteJob when TempData contains a pending preview.
|
||||
* - Opened manually via the "Send SMS" button; fetches a fresh template first.
|
||||
*
|
||||
* Requires window.__smsCompose to be set by the inline Razor script before this file loads.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const MODAL_ID = 'smsComposeModal';
|
||||
const TEXTAREA = 'smsMessageText';
|
||||
const CHAR_COUNT = 'smsCharCount';
|
||||
const STOP_WARN = 'smsStopWarning';
|
||||
const SEND_BTN = 'smsSendBtn';
|
||||
const ERROR_DIV = 'smsSendError';
|
||||
const MAX_CHARS = 160;
|
||||
const STOP_TOKEN = 'STOP';
|
||||
|
||||
function antiForgeryToken() {
|
||||
return document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
}
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
|
||||
function openModal(prefilledText) {
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(el(MODAL_ID));
|
||||
const textarea = el(TEXTAREA);
|
||||
if (textarea) textarea.value = prefilledText ?? '';
|
||||
updateCharCount();
|
||||
el(ERROR_DIV)?.classList.add('d-none');
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function updateCharCount() {
|
||||
const textarea = el(TEXTAREA);
|
||||
if (!textarea) return;
|
||||
const text = textarea.value;
|
||||
const len = text.length;
|
||||
const charEl = el(CHAR_COUNT);
|
||||
const warnEl = el(STOP_WARN);
|
||||
if (charEl) charEl.textContent = len;
|
||||
if (warnEl) {
|
||||
const hasStop = text.toUpperCase().includes(STOP_TOKEN);
|
||||
warnEl.classList.toggle('d-none', hasStop);
|
||||
}
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const div = el(ERROR_DIV);
|
||||
if (!div) return;
|
||||
div.textContent = msg;
|
||||
div.classList.remove('d-none');
|
||||
}
|
||||
|
||||
async function sendSms() {
|
||||
const cfg = window.__smsCompose ?? {};
|
||||
const btn = el(SEND_BTN);
|
||||
let message = el(TEXTAREA)?.value?.trim() ?? '';
|
||||
|
||||
if (!message) { showError('Please enter a message.'); return; }
|
||||
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Sending…'; }
|
||||
el(ERROR_DIV)?.classList.add('d-none');
|
||||
|
||||
try {
|
||||
const resp = await fetch(cfg.sendUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': antiForgeryToken()
|
||||
},
|
||||
body: JSON.stringify({ jobId: cfg.jobIdForSms, message })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(el(MODAL_ID))?.hide();
|
||||
// Show a quick success toast using existing toastSuccess pattern if available
|
||||
const toastEl = document.getElementById('successToast');
|
||||
const toastMsg = document.getElementById('successToastMessage');
|
||||
if (toastEl && toastMsg) {
|
||||
toastMsg.textContent = 'SMS sent successfully.';
|
||||
bootstrap.Toast.getOrCreateInstance(toastEl).show();
|
||||
}
|
||||
} else {
|
||||
showError(data.error ?? 'Failed to send SMS. Please try again.');
|
||||
}
|
||||
} catch {
|
||||
showError('A network error occurred. Please check your connection and try again.');
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="bi bi-send me-1"></i>Send SMS'; }
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const cfg = window.__smsCompose ?? {};
|
||||
|
||||
// Character counter
|
||||
el(TEXTAREA)?.addEventListener('input', updateCharCount);
|
||||
|
||||
// Send button
|
||||
el(SEND_BTN)?.addEventListener('click', sendSms);
|
||||
|
||||
// "Send SMS" button on the job details action bar
|
||||
document.getElementById('btnSendSms')?.addEventListener('click', async () => {
|
||||
if (!cfg.customerOptedIn) {
|
||||
alert('This customer has not opted in to SMS notifications.');
|
||||
return;
|
||||
}
|
||||
// Fetch a fresh render of the template
|
||||
try {
|
||||
const resp = await fetch(`${cfg.renderUrl}?jobId=${cfg.jobIdForSms}`);
|
||||
const data = await resp.json();
|
||||
openModal(data.eligible ? data.message : '');
|
||||
} catch {
|
||||
openModal('');
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-open after CompleteJob if server passed a pending preview
|
||||
if (cfg.pendingPreview) {
|
||||
// Small delay so the page has fully rendered
|
||||
setTimeout(() => openModal(cfg.pendingPreview), 400);
|
||||
}
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user