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:
@@ -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>
|
/// <summary>
|
||||||
/// Issues a standalone credit memo and increments the customer's CreditBalance.
|
/// Issues a standalone credit memo and increments the customer's CreditBalance.
|
||||||
/// Restricted to CompanyAdmin because credits affect the financial ledger. The memo
|
/// Restricted to CompanyAdmin because credits affect the financial ledger. The memo
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using AutoMapper;
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PowderCoating.Application.DTOs.Kiosk;
|
using PowderCoating.Application.DTOs.Kiosk;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
@@ -39,6 +40,9 @@ public class KioskController : Controller
|
|||||||
private readonly IHubContext<KioskHub> _kioskHub;
|
private readonly IHubContext<KioskHub> _kioskHub;
|
||||||
private readonly ILogger<KioskController> _logger;
|
private readonly ILogger<KioskController> _logger;
|
||||||
private readonly ICompanyLogoService _logoService;
|
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>
|
/// <summary>Initialises all dependencies for the kiosk controller.</summary>
|
||||||
public KioskController(
|
public KioskController(
|
||||||
@@ -49,7 +53,8 @@ public class KioskController : Controller
|
|||||||
IEmailService emailService,
|
IEmailService emailService,
|
||||||
IHubContext<KioskHub> kioskHub,
|
IHubContext<KioskHub> kioskHub,
|
||||||
ILogger<KioskController> logger,
|
ILogger<KioskController> logger,
|
||||||
ICompanyLogoService logoService)
|
ICompanyLogoService logoService,
|
||||||
|
IMemoryCache cache)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -59,6 +64,7 @@ public class KioskController : Controller
|
|||||||
_kioskHub = kioskHub;
|
_kioskHub = kioskHub;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_logoService = logoService;
|
_logoService = logoService;
|
||||||
|
_cache = cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -104,6 +110,10 @@ public class KioskController : Controller
|
|||||||
if (company == null || company.KioskActivationToken != cookie.Value.token)
|
if (company == null || company.KioskActivationToken != cookie.Value.token)
|
||||||
return Json(new { hasSession = false });
|
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 window = DateTime.UtcNow.AddSeconds(-60);
|
||||||
var session = await _unitOfWork.KioskSessions.FirstOrDefaultAsync(
|
var session = await _unitOfWork.KioskSessions.FirstOrDefaultAsync(
|
||||||
s => s.CompanyId == cookie.Value.companyId
|
s => s.CompanyId == cookie.Value.companyId
|
||||||
@@ -116,6 +126,101 @@ public class KioskController : Controller
|
|||||||
return Json(new { hasSession = true, sessionToken = session.SessionToken });
|
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>
|
/// <summary>
|
||||||
/// Serves the company logo for anonymous kiosk pages. Resolves the company from the
|
/// 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.
|
/// 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">
|
<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
|
<i class="bi bi-chat-slash me-1"></i>SMS off
|
||||||
</span>
|
</span>
|
||||||
<a href="/Customers/SmsConsent/@Model.Id"
|
<button type="button"
|
||||||
class="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 text-decoration-none"
|
class="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25 border-0"
|
||||||
title="Present SMS consent form to customer">
|
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
|
<i class="bi bi-chat-dots me-1"></i>Get SMS Consent
|
||||||
</a>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -549,3 +551,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="~/js/customer-details.js" asp-append-version="true"></script>
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -9,7 +9,7 @@
|
|||||||
<h2 class="fw-bold mb-1" style="font-size:1.6rem;">SMS Notifications</h2>
|
<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>
|
<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()
|
@Html.AntiForgeryToken()
|
||||||
<input type="hidden" name="agreed" value="true" />
|
<input type="hidden" name="agreed" value="true" />
|
||||||
|
|
||||||
@@ -33,7 +33,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex gap-3">
|
<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;">
|
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
|
<i class="bi bi-x-lg me-1"></i> No Thanks
|
||||||
</a>
|
</a>
|
||||||
@@ -42,4 +44,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
@@ -2101,8 +2101,14 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load on page ready
|
// Load on page ready and refresh when dropdown is opened
|
||||||
document.addEventListener('DOMContentLoaded', load);
|
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 };
|
return { addItem, incrementBadge, markAllRead, openDetail, markRead };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ body.kiosk-body {
|
|||||||
width: 100%;
|
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 */
|
/* Suppress all hover effects on touch screens */
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
.kiosk-body .btn:hover { filter: none; opacity: 1; }
|
.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);
|
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setStatus("#16a34a", "Ready");
|
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) {
|
if (data.hasSession && data.sessionToken) {
|
||||||
active = false;
|
active = false;
|
||||||
setStatus("#2563eb", "Starting…");
|
setStatus("#2563eb", "Starting…");
|
||||||
|
|||||||
Reference in New Issue
Block a user