diff --git a/src/PowderCoating.Web/Controllers/CustomersController.cs b/src/PowderCoating.Web/Controllers/CustomersController.cs
index 2da409c..4e327d8 100644
--- a/src/PowderCoating.Web/Controllers/CustomersController.cs
+++ b/src/PowderCoating.Web/Controllers/CustomersController.cs
@@ -877,74 +877,6 @@ public class CustomersController : Controller
}
}
- ///
- /// 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.
- ///
- // GET: Customers/SmsConsent/5
- public async Task 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);
- }
-
- ///
- /// 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.
- ///
- // POST: Customers/SmsConsent/5
- [HttpPost, ValidateAntiForgeryToken]
- public async Task 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 });
- }
-
///
/// Issues a standalone credit memo and increments the customer's CreditBalance.
/// Restricted to CompanyAdmin because credits affect the financial ledger. The memo
diff --git a/src/PowderCoating.Web/Controllers/KioskController.cs b/src/PowderCoating.Web/Controllers/KioskController.cs
index 993cc99..5ceb1cb 100644
--- a/src/PowderCoating.Web/Controllers/KioskController.cs
+++ b/src/PowderCoating.Web/Controllers/KioskController.cs
@@ -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;
private readonly ILogger _logger;
private readonly ICompanyLogoService _logoService;
+ private readonly IMemoryCache _cache;
+
+ private static string SmsConsentCacheKey(int companyId) => $"kiosk-sms-consent:{companyId}";
/// Initialises all dependencies for the kiosk controller.
public KioskController(
@@ -49,7 +53,8 @@ public class KioskController : Controller
IEmailService emailService,
IHubContext kioskHub,
ILogger 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)
+ // =========================================================================
+
+ ///
+ /// 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.
+ ///
+ [HttpPost, ValidateAntiForgeryToken]
+ public async Task 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 });
+ }
+
+ ///
+ /// 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.
+ ///
+ [AllowAnonymous]
+ public async Task 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [AllowAnonymous, HttpPost]
+ public async Task 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");
+ }
+
///
/// 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.
diff --git a/src/PowderCoating.Web/Views/Customers/Details.cshtml b/src/PowderCoating.Web/Views/Customers/Details.cshtml
index 6281ff7..cf2bfec 100644
--- a/src/PowderCoating.Web/Views/Customers/Details.cshtml
+++ b/src/PowderCoating.Web/Views/Customers/Details.cshtml
@@ -185,11 +185,13 @@
SMS off
-
+
+
}
@@ -549,3 +551,8 @@
}
+
+@section Scripts {
+
+}
+
diff --git a/src/PowderCoating.Web/Views/Customers/SmsConsent.cshtml b/src/PowderCoating.Web/Views/Kiosk/SmsConsent.cshtml
similarity index 76%
rename from src/PowderCoating.Web/Views/Customers/SmsConsent.cshtml
rename to src/PowderCoating.Web/Views/Kiosk/SmsConsent.cshtml
index f84de17..fb71d76 100644
--- a/src/PowderCoating.Web/Views/Customers/SmsConsent.cshtml
+++ b/src/PowderCoating.Web/Views/Kiosk/SmsConsent.cshtml
@@ -9,7 +9,7 @@
SMS Notifications
Please read the following and tap I Agree to opt in.
-
+
+ @* Separate form for decline so "No Thanks" can POST with agreed=false *@
+
diff --git a/src/PowderCoating.Web/Views/Shared/_Layout.cshtml b/src/PowderCoating.Web/Views/Shared/_Layout.cshtml
index ae9ed26..a49db1e 100644
--- a/src/PowderCoating.Web/Views/Shared/_Layout.cshtml
+++ b/src/PowderCoating.Web/Views/Shared/_Layout.cshtml
@@ -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 };
})();
diff --git a/src/PowderCoating.Web/wwwroot/css/kiosk.css b/src/PowderCoating.Web/wwwroot/css/kiosk.css
index b71ab8a..46e6efb 100644
--- a/src/PowderCoating.Web/wwwroot/css/kiosk.css
+++ b/src/PowderCoating.Web/wwwroot/css/kiosk.css
@@ -60,6 +60,14 @@ body.kiosk-body {
width: 100%;
}
+/* Vertically centre content in any tall kiosk button (covers and