Fix notification bell, SMS consent kiosk flow, and button alignment

Notification bell:
- Bell now polls /InAppNotifications/Recent every 60s as a SignalR fallback
- Bell dropdown refresh on open so count is always current when staff looks at it

SMS consent → kiosk flow:
- Staff clicks "Get SMS Consent" on Customer Details → AJAX POST to
  /Kiosk/PushSmsConsent stores customer in IMemoryCache (10 min TTL)
- Kiosk PollSession returns smsConsentPending + customerId so tablet navigates
  to /Kiosk/SmsConsent/{customerId} automatically
- Customer reads TCPA consent on tablet, taps I Agree or No Thanks
- On agree: NotifyBySms/SmsConsentedAt/SmsConsentMethod set; in-app notification
  fires; cache cleared; tablet returns to Welcome
- Removed Customers/SmsConsent (staff-browser version); moved view to Kiosk/

Button alignment:
- kiosk.css: added display:flex + align-items:center + justify-content:center to
  all kiosk body buttons so content is centred vertically in tall button outlines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-13 23:13:57 -04:00
parent b69ff6db3a
commit e1256503be
8 changed files with 168 additions and 77 deletions
@@ -877,74 +877,6 @@ public class CustomersController : Controller
}
}
/// <summary>
/// Displays a full-screen SMS consent form for the customer to read and agree to.
/// Staff opens this page on a tablet and hands it to the customer; no staff account
/// interaction is required — the page is scoped to the customer by ID only.
/// Redirects back to Details if the customer has already consented.
/// </summary>
// GET: Customers/SmsConsent/5
public async Task<IActionResult> SmsConsent(int id)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null) return NotFound();
if (customer.NotifyBySms)
{
this.ToastInfo("This customer has already given SMS consent.");
return RedirectToAction(nameof(Details), new { id });
}
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId.HasValue)
{
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
ViewBag.CompanyName = company?.CompanyName;
ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company?.LogoFilePath)
? Url.Action("Logo", "Kiosk")
: null;
}
ViewBag.ShowInactivityTimer = false;
ViewBag.CustomerName = $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
if (string.IsNullOrWhiteSpace(ViewBag.CustomerName as string) && !string.IsNullOrEmpty(customer.CompanyName))
ViewBag.CustomerName = customer.CompanyName;
return View(customer.Id);
}
/// <summary>
/// Records the customer's SMS consent: sets NotifyBySms, SmsConsentedAt (UTC now),
/// and SmsConsentMethod = "InPerson". Called when the customer taps "I Agree" on the
/// consent form presented by staff.
/// </summary>
// POST: Customers/SmsConsent/5
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> SmsConsent(int id, bool agreed)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null) return NotFound();
if (!agreed)
{
this.ToastError("Customer did not agree to SMS consent.");
return RedirectToAction(nameof(Details), new { id });
}
customer.NotifyBySms = true;
customer.SmsConsentedAt = DateTime.UtcNow;
customer.SmsConsentMethod = "InPerson";
customer.SmsOptedOutAt = null;
await _unitOfWork.Customers.UpdateAsync(customer);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("SMS consent recorded for customer {CustomerId} via staff-presented form", id);
this.ToastSuccess($"SMS consent recorded for {customer.ContactFirstName} {customer.ContactLastName}.");
return RedirectToAction(nameof(Details), new { id });
}
/// <summary>
/// Issues a standalone credit memo and increments the customer's CreditBalance.
/// Restricted to CompanyAdmin because credits affect the financial ledger. The memo
@@ -2,6 +2,7 @@ using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Kiosk;
using PowderCoating.Application.Interfaces;
@@ -39,6 +40,9 @@ public class KioskController : Controller
private readonly IHubContext<KioskHub> _kioskHub;
private readonly ILogger<KioskController> _logger;
private readonly ICompanyLogoService _logoService;
private readonly IMemoryCache _cache;
private static string SmsConsentCacheKey(int companyId) => $"kiosk-sms-consent:{companyId}";
/// <summary>Initialises all dependencies for the kiosk controller.</summary>
public KioskController(
@@ -49,7 +53,8 @@ public class KioskController : Controller
IEmailService emailService,
IHubContext<KioskHub> kioskHub,
ILogger<KioskController> logger,
ICompanyLogoService logoService)
ICompanyLogoService logoService,
IMemoryCache cache)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -59,6 +64,7 @@ public class KioskController : Controller
_kioskHub = kioskHub;
_logger = logger;
_logoService = logoService;
_cache = cache;
}
// =========================================================================
@@ -104,6 +110,10 @@ public class KioskController : Controller
if (company == null || company.KioskActivationToken != cookie.Value.token)
return Json(new { hasSession = false });
// Check for a staff-pushed SMS consent request before checking for intake sessions.
if (_cache.TryGetValue(SmsConsentCacheKey(cookie.Value.companyId), out (int customerId, string customerName) pending))
return Json(new { hasSession = false, smsConsentPending = true, customerId = pending.customerId, customerName = pending.customerName });
var window = DateTime.UtcNow.AddSeconds(-60);
var session = await _unitOfWork.KioskSessions.FirstOrDefaultAsync(
s => s.CompanyId == cookie.Value.companyId
@@ -116,6 +126,101 @@ public class KioskController : Controller
return Json(new { hasSession = true, sessionToken = session.SessionToken });
}
// =========================================================================
// SMS CONSENT (staff pushes to kiosk; customer agrees on tablet)
// =========================================================================
/// <summary>
/// Staff calls this (authenticated) from the Customer Details page to push an SMS
/// consent request to the front-desk kiosk tablet. Stores the customer ID in
/// IMemoryCache under a company-scoped key; the kiosk's PollSession endpoint picks
/// it up and returns smsConsentPending so the tablet can navigate to the consent page.
/// The cache entry expires in 10 minutes in case the customer never approaches the tablet.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PushSmsConsent(int customerId)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
if (customer == null) return Json(new { success = false, message = "Customer not found." });
if (customer.NotifyBySms)
return Json(new { success = false, message = "Customer has already given SMS consent." });
var companyId = customer.CompanyId;
var name = !string.IsNullOrWhiteSpace(customer.ContactFirstName)
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: customer.CompanyName ?? "Customer";
_cache.Set(SmsConsentCacheKey(companyId), (customerId, name),
new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
_logger.LogInformation("SMS consent pushed to kiosk for customer {CustomerId} by staff", customerId);
return Json(new { success = true });
}
/// <summary>
/// Displays the full-screen SMS consent form on the kiosk tablet (anonymous, kiosk layout).
/// Loads the customer by ID with ignoreQueryFilters because the kiosk has no tenant context.
/// </summary>
[AllowAnonymous]
public async Task<IActionResult> SmsConsent(int customerId)
{
var cookie = ReadKioskCookie();
if (cookie == null) return Forbid();
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId, ignoreQueryFilters: true);
if (customer == null) return NotFound();
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
ViewBag.CompanyName = company?.CompanyName;
ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company?.LogoFilePath) ? Url.Action("Logo", "Kiosk") : null;
ViewBag.ShowInactivityTimer = false;
ViewBag.CustomerName = !string.IsNullOrWhiteSpace(customer.ContactFirstName)
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: customer.CompanyName ?? "Customer";
return View(customerId);
}
/// <summary>
/// Records the customer's SMS consent from the kiosk tablet and clears the pending cache entry.
/// Sets NotifyBySms, SmsConsentedAt, SmsConsentMethod = "KioskInPerson" on the customer record.
/// </summary>
[AllowAnonymous, HttpPost]
public async Task<IActionResult> SmsConsent(int customerId, bool agreed)
{
var cookie = ReadKioskCookie();
if (cookie == null) return Forbid();
// Always clear the pending consent so the kiosk stops showing the form
_cache.Remove(SmsConsentCacheKey(cookie.Value.companyId));
if (agreed)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId, ignoreQueryFilters: true);
if (customer != null)
{
customer.NotifyBySms = true;
customer.SmsConsentedAt = DateTime.UtcNow;
customer.SmsConsentMethod = "KioskInPerson";
customer.SmsOptedOutAt = null;
await _unitOfWork.Customers.UpdateAsync(customer);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("SMS consent recorded via kiosk for customer {CustomerId}", customerId);
await _inApp.CreateAsync(
customer.CompanyId,
"SMS Consent Recorded",
$"{customer.ContactFirstName} {customer.ContactLastName} agreed to SMS notifications on the kiosk.",
"KioskConsent",
link: $"/Customers/Details/{customerId}",
customerId: customerId);
}
}
return Redirect("/Kiosk/Welcome");
}
/// <summary>
/// Serves the company logo for anonymous kiosk pages. Resolves the company from the
/// KioskDevice cookie so no tenant context is needed on the anonymous request.
@@ -185,11 +185,13 @@
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary border-opacity-25">
<i class="bi bi-chat-slash me-1"></i>SMS off
</span>
<a href="/Customers/SmsConsent/@Model.Id"
class="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 text-decoration-none"
title="Present SMS consent form to customer">
<button type="button"
class="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 border-0"
style="cursor:pointer;"
title="Send SMS consent form to the front-desk kiosk tablet"
onclick="pushSmsConsent(@Model.Id)">
<i class="bi bi-chat-dots me-1"></i>Get SMS Consent
</a>
</button>
}
</div>
</div>
@@ -549,3 +551,8 @@
</div>
</div>
}
@section Scripts {
<script src="~/js/customer-details.js" asp-append-version="true"></script>
}
@@ -9,7 +9,7 @@
<h2 class="fw-bold mb-1" style="font-size:1.6rem;">SMS Notifications</h2>
<p class="text-muted mb-4">Please read the following and tap <strong>I Agree</strong> to opt in.</p>
<form method="post" action="/Customers/SmsConsent/@Model">
<form method="post" action="/Kiosk/SmsConsent/@Model">
@Html.AntiForgeryToken()
<input type="hidden" name="agreed" value="true" />
@@ -33,7 +33,9 @@
</div>
<div class="d-flex gap-3">
<a href="/Customers/Details/@Model" class="btn btn-outline-secondary"
<a href="/Kiosk/SmsConsent/@Model?agreed=false"
onclick="event.preventDefault(); document.getElementById('declineForm').submit();"
class="btn btn-outline-secondary"
style="min-height:64px;border-radius:12px;font-size:1.1rem;flex:0 0 auto;padding:0 2rem;">
<i class="bi bi-x-lg me-1"></i> No Thanks
</a>
@@ -42,4 +44,10 @@
</button>
</div>
</form>
@* Separate form for decline so "No Thanks" can POST with agreed=false *@
<form id="declineForm" method="post" action="/Kiosk/SmsConsent/@Model" style="display:none;">
@Html.AntiForgeryToken()
<input type="hidden" name="agreed" value="false" />
</form>
</div>
@@ -2101,8 +2101,14 @@
});
});
// Load on page ready
document.addEventListener('DOMContentLoaded', load);
// Load on page ready and refresh when dropdown is opened
document.addEventListener('DOMContentLoaded', () => {
load();
btn?.addEventListener('show.bs.dropdown', load);
});
// Fallback poll every 60 s in case SignalR misses a push
setInterval(load, 60_000);
return { addItem, incrementBadge, markAllRead, openDetail, markRead };
})();
@@ -60,6 +60,14 @@ body.kiosk-body {
width: 100%;
}
/* Vertically centre content in any tall kiosk button (covers <a> and <button>) */
.kiosk-body .btn,
.kiosk-btn {
display: inline-flex;
align-items: center;
justify-content: center;
}
/* Suppress all hover effects on touch screens */
@media (hover: none) {
.kiosk-body .btn:hover { filter: none; opacity: 1; }
@@ -0,0 +1,19 @@
"use strict";
async function pushSmsConsent(customerId) {
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
try {
const res = await fetch(`/Kiosk/PushSmsConsent?customerId=${customerId}`, {
method: 'POST',
headers: { 'RequestVerificationToken': tok }
});
const data = await res.json();
if (data.success) {
toastr.success('Consent form sent to the kiosk tablet — hand it to the customer.', 'Sent to Kiosk');
} else {
toastr.warning(data.message || 'Could not send consent to kiosk.');
}
} catch {
toastr.error('An error occurred. Please try again.');
}
}
@@ -26,6 +26,12 @@
if (!res.ok) throw new Error("HTTP " + res.status);
const data = await res.json();
setStatus("#16a34a", "Ready");
if (data.smsConsentPending && data.customerId) {
active = false;
setStatus("#2563eb", "Loading consent…");
window.location.href = `/Kiosk/SmsConsent/${data.customerId}`;
return;
}
if (data.hasSession && data.sessionToken) {
active = false;
setStatus("#2563eb", "Starting…");