Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc3cd75ea4 | |||
| a73f14fa7f | |||
| 0af31c39b3 | |||
| e1256503be | |||
| b69ff6db3a | |||
| 66231822af | |||
| d5ad9fa073 | |||
| d134dd51e5 | |||
| 1df7c13abd | |||
| 4a8778504f | |||
| f1d7054b3e | |||
| 46b950baf2 | |||
| 4e9c9d321a | |||
| 0c8723ef84 | |||
| 377bb1ce38 | |||
| 2acf54e1a9 | |||
| 0b24c320cd |
@@ -59,6 +59,9 @@ public class CompanyPreferencesDto
|
||||
// Blank Work Order PDF Template
|
||||
public string WoAccentColor { get; set; } = "#374151";
|
||||
public string? WoTerms { get; set; }
|
||||
|
||||
// Kiosk settings
|
||||
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||
}
|
||||
|
||||
public class UpdateAppDefaultsDto
|
||||
@@ -136,3 +139,11 @@ public class UpdateWorkOrderTemplateDto
|
||||
public string WoAccentColor { get; set; } = "#374151";
|
||||
[StringLength(2000)] public string? WoTerms { get; set; }
|
||||
}
|
||||
|
||||
|
||||
public class UpdateKioskSettingsDto
|
||||
{
|
||||
/// <summary>"Quote" (default) or "Job" — what the kiosk creates on submission.</summary>
|
||||
[Required]
|
||||
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||
}
|
||||
|
||||
@@ -76,12 +76,13 @@ public class KioskSessionListDto
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public int? LinkedCustomerId { get; set; }
|
||||
public int? LinkedJobId { get; set; }
|
||||
public int? LinkedQuoteId { get; set; }
|
||||
public string? RemoteLinkEmail { get; set; }
|
||||
|
||||
public string CustomerFullName => $"{CustomerFirstName} {CustomerLastName}".Trim();
|
||||
public string JobDescriptionSnippet =>
|
||||
JobDescription.Length > 80 ? JobDescription[..80] + "…" : JobDescription;
|
||||
public bool IsConverted => LinkedJobId.HasValue;
|
||||
public bool IsConverted => LinkedJobId.HasValue || LinkedQuoteId.HasValue;
|
||||
public bool IsExpired => Status == KioskSessionStatus.Expired ||
|
||||
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
|
||||
}
|
||||
|
||||
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
|
||||
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateKioskSettingsDto, CompanyPreferences>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +86,14 @@ public class CompanyPreferences : BaseEntity
|
||||
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
||||
public string? QbMigrationStateJson { get; set; }
|
||||
|
||||
// Kiosk settings
|
||||
/// <summary>
|
||||
/// Controls what the kiosk creates on submission: "Quote" (default) or "Job".
|
||||
/// Quote aligns with the default Terms text ("subject to a formal quote").
|
||||
/// Job is for shops that price on the spot and want the work order ready immediately.
|
||||
/// </summary>
|
||||
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||
|
||||
// Guided activation / first-workflow onboarding
|
||||
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
||||
public string? OnboardingPath { get; set; }
|
||||
|
||||
@@ -36,7 +36,10 @@ public class KioskSession : BaseEntity
|
||||
|
||||
// ── Outcome ───────────────────────────────────────────────────────────────
|
||||
public int? LinkedCustomerId { get; set; }
|
||||
/// <summary>Set when KioskIntakeOutput = "Job". Null when a Quote was created instead.</summary>
|
||||
public int? LinkedJobId { get; set; }
|
||||
/// <summary>Set when KioskIntakeOutput = "Quote". Null when a Job was created instead.</summary>
|
||||
public int? LinkedQuoteId { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
|
||||
Generated
+10742
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddKioskIntakeOutputSetting : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "LinkedQuoteId",
|
||||
table: "KioskSessions",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "KioskIntakeOutput",
|
||||
table: "CompanyPreferences",
|
||||
type: "nvarchar(max)",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LinkedQuoteId",
|
||||
table: "KioskSessions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "KioskIntakeOutput",
|
||||
table: "CompanyPreferences");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2253,6 +2253,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int>("JobRetentionYears")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("KioskIntakeOutput")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("LogRetentionDays")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -5637,6 +5641,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int?>("LinkedJobId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("LinkedQuoteId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("RemoteLinkEmail")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -6692,7 +6699,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259),
|
||||
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6703,7 +6710,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264),
|
||||
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6714,7 +6721,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266),
|
||||
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
|
||||
@@ -543,6 +543,15 @@ public class CompanySettingsController : Controller
|
||||
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
|
||||
UpdatePreferences(dto, "Work order settings saved successfully.");
|
||||
|
||||
/// <summary>
|
||||
/// Saves kiosk intake output preference ("Quote" or "Job") to <see cref="CompanyPreferences"/>.
|
||||
/// Delegates to <see cref="UpdatePreferences{TDto}"/>.
|
||||
/// </summary>
|
||||
// POST: CompanySettings/UpdateKioskSettings
|
||||
[HttpPost]
|
||||
public Task<IActionResult> UpdateKioskSettings([FromBody] UpdateKioskSettingsDto dto) =>
|
||||
UpdatePreferences(dto, "Kiosk settings saved successfully.");
|
||||
|
||||
/// <summary>
|
||||
/// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
|
||||
/// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
|
||||
|
||||
@@ -125,5 +125,13 @@ namespace PowderCoating.Web.Controllers
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the Customer Intake Kiosk help article explaining the tablet kiosk setup, the staff-triggered intake flow, and the Intakes review page.
|
||||
/// </summary>
|
||||
public IActionResult CustomerIntakeKiosk()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -68,7 +74,8 @@ public class KioskController : Controller
|
||||
/// <summary>
|
||||
/// Idle branded screen displayed on the front-desk tablet.
|
||||
/// Validates the KioskDevice cookie; returns 403 if missing or token mismatch.
|
||||
/// The view connects to KioskHub and listens for StartIntake events.
|
||||
/// The view polls /Kiosk/PollSession every 3 seconds and navigates when staff
|
||||
/// triggers a session via the Dashboard "Start Intake" button.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> Welcome()
|
||||
@@ -86,6 +93,149 @@ public class KioskController : Controller
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight polling endpoint called every 3 seconds by the kiosk Welcome screen.
|
||||
/// Returns the most recent InPerson KioskSession created in the last 60 seconds so
|
||||
/// the tablet can navigate without relying on SignalR (which Azure App Service blocks
|
||||
/// for anonymous WebSocket/SSE connections through its ingress proxy).
|
||||
/// </summary>
|
||||
[AllowAnonymous, HttpGet]
|
||||
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
|
||||
public async Task<IActionResult> PollSession()
|
||||
{
|
||||
var cookie = ReadKioskCookie();
|
||||
if (cookie == null) return Json(new { hasSession = false });
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
|
||||
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
|
||||
&& s.SessionType == KioskSessionType.InPerson
|
||||
&& s.Status == KioskSessionStatus.Active
|
||||
&& s.CreatedAt >= window,
|
||||
ignoreQueryFilters: true);
|
||||
|
||||
if (session == null) return Json(new { hasSession = false });
|
||||
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>
|
||||
/// Cancels a pending kiosk SMS consent request, freeing the kiosk to return to the Welcome
|
||||
/// screen. Called by staff if they pushed consent accidentally or the customer isn't coming.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public IActionResult CancelSmsConsent()
|
||||
{
|
||||
var companyId = HttpContext.User.FindFirst("CompanyId")?.Value;
|
||||
if (int.TryParse(companyId, out var cid))
|
||||
_cache.Remove(SmsConsentCacheKey(cid));
|
||||
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 id)
|
||||
{
|
||||
var cookie = ReadKioskCookie();
|
||||
if (cookie == null) return Forbid();
|
||||
|
||||
// Clear the pending entry immediately — the kiosk is now showing the form,
|
||||
// so Welcome must not redirect again if the customer cancels or navigates back.
|
||||
_cache.Remove(SmsConsentCacheKey(cookie.Value.companyId));
|
||||
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id, 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(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the customer's SMS consent from the kiosk tablet.
|
||||
/// Sets NotifyBySms, SmsConsentedAt, SmsConsentMethod = "KioskInPerson" on the customer record.
|
||||
/// Cache is already cleared by the GET; this handles the agree/decline outcome.
|
||||
/// </summary>
|
||||
[AllowAnonymous, HttpPost]
|
||||
public async Task<IActionResult> SmsConsent(int id, bool agreed)
|
||||
{
|
||||
var cookie = ReadKioskCookie();
|
||||
if (cookie == null) return Forbid();
|
||||
|
||||
if (agreed)
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id, 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}", id);
|
||||
|
||||
await _inApp.CreateAsync(
|
||||
customer.CompanyId,
|
||||
"SMS Consent Recorded",
|
||||
$"{customer.ContactFirstName} {customer.ContactLastName} agreed to SMS notifications on the kiosk.",
|
||||
"KioskConsent",
|
||||
link: $"/Customers/Details/{id}",
|
||||
customerId: id);
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -261,7 +411,7 @@ public class KioskController : Controller
|
||||
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 1;
|
||||
return View(new SubmitKioskContactDto
|
||||
return View("Intake/Contact", new SubmitKioskContactDto
|
||||
{
|
||||
FirstName = session.CustomerFirstName,
|
||||
LastName = session.CustomerLastName,
|
||||
@@ -283,7 +433,7 @@ public class KioskController : Controller
|
||||
{
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 1;
|
||||
return View(dto);
|
||||
return View("Intake/Contact", dto);
|
||||
}
|
||||
|
||||
session.CustomerFirstName = dto.FirstName.Trim();
|
||||
@@ -308,7 +458,7 @@ public class KioskController : Controller
|
||||
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 2;
|
||||
return View(new SubmitKioskJobDto
|
||||
return View("Intake/Job", new SubmitKioskJobDto
|
||||
{
|
||||
JobDescription = session.JobDescription,
|
||||
HowDidYouHearAboutUs = session.HowDidYouHearAboutUs
|
||||
@@ -327,7 +477,7 @@ public class KioskController : Controller
|
||||
{
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 2;
|
||||
return View(dto);
|
||||
return View("Intake/Job", dto);
|
||||
}
|
||||
|
||||
session.JobDescription = dto.JobDescription.Trim();
|
||||
@@ -350,7 +500,7 @@ public class KioskController : Controller
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 3;
|
||||
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
|
||||
return View(new SubmitKioskTermsDto());
|
||||
return View("Intake/Terms", new SubmitKioskTermsDto());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -364,6 +514,9 @@ public class KioskController : Controller
|
||||
var session = await LoadSessionAsync(token);
|
||||
if (session == null) return View("KioskError", "Session not found.");
|
||||
|
||||
// Expired/already-submitted sessions go straight to Confirmation
|
||||
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
|
||||
|
||||
// Require signature for in-person sessions
|
||||
if (session.SessionType == KioskSessionType.InPerson &&
|
||||
string.IsNullOrEmpty(dto.SignatureDataBase64))
|
||||
@@ -376,7 +529,7 @@ public class KioskController : Controller
|
||||
await PopulateKioskViewBagFromSession(session);
|
||||
ViewBag.KioskStep = 3;
|
||||
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
|
||||
return View(dto);
|
||||
return View("Intake/Terms", dto);
|
||||
}
|
||||
|
||||
session.AgreedToTerms = true;
|
||||
@@ -393,8 +546,9 @@ public class KioskController : Controller
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing kiosk submission for session {SessionToken}", token);
|
||||
// Don't fail the customer-facing page — save what we have and let staff convert manually
|
||||
await _unitOfWork.CompleteAsync();
|
||||
// Customer-facing page always succeeds — staff can convert the session manually.
|
||||
// Persist the session's agreed/submitted state even if job creation failed.
|
||||
try { await _unitOfWork.CompleteAsync(); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Confirmation), new { token });
|
||||
@@ -413,7 +567,7 @@ public class KioskController : Controller
|
||||
ViewBag.ShowInactivityTimer = false; // Handled by the countdown JS in the view
|
||||
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
|
||||
ViewBag.FirstName = session.CustomerFirstName;
|
||||
return View();
|
||||
return View("Intake/Confirmation");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -449,6 +603,7 @@ public class KioskController : Controller
|
||||
ExpiresAt = s.ExpiresAt,
|
||||
LinkedCustomerId = s.LinkedCustomerId,
|
||||
LinkedJobId = s.LinkedJobId,
|
||||
LinkedQuoteId = s.LinkedQuoteId,
|
||||
RemoteLinkEmail = s.RemoteLinkEmail
|
||||
})
|
||||
.ToList();
|
||||
@@ -556,48 +711,117 @@ public class KioskController : Controller
|
||||
: "RemoteIntake";
|
||||
}
|
||||
|
||||
// 3. Create Job in Pending status
|
||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
||||
// 3. Resolve company preference: create a Quote (default) or a Job
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
var intakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
|
||||
var createQuote = !string.Equals(intakeOutput, "Job", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var jobNumber = await GenerateJobNumberAsync(companyId);
|
||||
var job = new Job
|
||||
session.LinkedCustomerId = customer!.Id;
|
||||
|
||||
if (createQuote)
|
||||
{
|
||||
CompanyId = companyId,
|
||||
CustomerId = customer!.Id,
|
||||
JobNumber = jobNumber,
|
||||
JobStatusId = pendingStatus?.Id ?? 1,
|
||||
SpecialInstructions = session.JobDescription,
|
||||
Description = $"Walk-in intake — {session.CustomerFirstName} {session.CustomerLastName}".Trim()
|
||||
};
|
||||
// 3a. Create a Draft Quote so staff can price and send for approval
|
||||
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
||||
var draftStatus = quoteStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
|
||||
if (draftStatus == null)
|
||||
throw new InvalidOperationException($"No Draft quote status found for company {companyId}. Run Seed Data from Platform Management.");
|
||||
|
||||
await _unitOfWork.Jobs.AddAsync(job);
|
||||
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
|
||||
var quote = new Quote
|
||||
{
|
||||
CompanyId = companyId,
|
||||
CustomerId = customer.Id,
|
||||
QuoteNumber = quoteNumber,
|
||||
QuoteStatusId = draftStatus.Id,
|
||||
Description = session.JobDescription,
|
||||
Notes = $"Source: {session.SessionType} kiosk intake",
|
||||
QuoteDate = DateTime.UtcNow,
|
||||
ExpirationDate = DateTime.UtcNow.AddDays(prefs?.DefaultQuoteValidityDays ?? 30)
|
||||
};
|
||||
|
||||
// 4. Update session links
|
||||
session.LinkedCustomerId = customer.Id;
|
||||
session.LinkedJobId = job.Id; // will be populated after SaveChanges below
|
||||
await _unitOfWork.Quotes.AddAsync(quote);
|
||||
await _unitOfWork.CompleteAsync(); // quote.Id now valid
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// job.Id is now set — update session again if needed
|
||||
if (session.LinkedJobId == 0)
|
||||
session.LinkedQuoteId = quote.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
// 3b. Create a Pending Job directly (for shops that price on the spot)
|
||||
var jobStatuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||||
var pendingStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
||||
if (pendingStatus == null)
|
||||
throw new InvalidOperationException($"No Pending job status found for company {companyId}. Run Seed Data from Platform Management.");
|
||||
|
||||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
||||
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL")
|
||||
?? priorities.FirstOrDefault();
|
||||
if (normalPriority == null)
|
||||
throw new InvalidOperationException($"No job priority rows found for company {companyId}. Run Seed Data from Platform Management.");
|
||||
|
||||
var jobNumber = await GenerateJobNumberAsync(companyId);
|
||||
var job = new Job
|
||||
{
|
||||
CompanyId = companyId,
|
||||
CustomerId = customer.Id,
|
||||
JobNumber = jobNumber,
|
||||
JobStatusId = pendingStatus.Id,
|
||||
JobPriorityId = normalPriority.Id,
|
||||
Description = session.JobDescription,
|
||||
SpecialInstructions = $"Source: {session.SessionType} kiosk intake"
|
||||
};
|
||||
|
||||
await _unitOfWork.Jobs.AddAsync(job);
|
||||
await _unitOfWork.CompleteAsync(); // job.Id now valid
|
||||
|
||||
session.LinkedJobId = job.Id;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
// 4. Persist session links
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// 5. Fire staff notification
|
||||
var snippet = session.JobDescription.Length > 60 ? session.JobDescription[..60] + "…" : session.JobDescription;
|
||||
var fullName = $"{session.CustomerFirstName} {session.CustomerLastName}".Trim();
|
||||
var jobDesc = session.JobDescription ?? "";
|
||||
var snippet = jobDesc.Length > 60 ? jobDesc[..60] + "…" : jobDesc;
|
||||
var fullName = $"{session.CustomerFirstName} {session.CustomerLastName}".Trim();
|
||||
var intakeLabel = session.SessionType == KioskSessionType.Remote ? "Remote Intake" : "Walk-in Intake";
|
||||
await _inApp.CreateAsync(
|
||||
companyId,
|
||||
"Walk-in Intake Submitted",
|
||||
$"{intakeLabel} Submitted",
|
||||
$"{fullName} completed their intake form — {snippet}",
|
||||
"KioskIntake",
|
||||
link: $"/Kiosk/Intakes",
|
||||
customerId: customer.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential quote number using the company's configured prefix.
|
||||
/// Mirrors GenerateQuoteNumberAsync in QuotesController — same format: PREFIX-YYMM-####.
|
||||
/// Implemented here because KioskController processes anonymous requests and cannot
|
||||
/// rely on ITenantContext to resolve the company ID.
|
||||
/// </summary>
|
||||
private async Task<string> GenerateQuoteNumberAsync(int companyId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
|
||||
var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT";
|
||||
var prefix = $"{quotePrefix}-{now:yy}{now:MM}";
|
||||
|
||||
var lastQuoteNumber = await _unitOfWork.Quotes.GetLastQuoteNumberByPrefixAsync(companyId, prefix);
|
||||
|
||||
if (lastQuoteNumber != null)
|
||||
{
|
||||
var lastNumberStr = lastQuoteNumber[(prefix.Length + 1)..];
|
||||
if (int.TryParse(lastNumberStr, out int lastNumber))
|
||||
return $"{prefix}-{(lastNumber + 1):D4}";
|
||||
}
|
||||
|
||||
return $"{prefix}-0001";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential job number using the company's configured prefix.
|
||||
/// Mirrors the logic in JobsController.GenerateJobNumber() — same format: PREFIX-YYMM-####.
|
||||
@@ -675,7 +899,11 @@ public class KioskController : Controller
|
||||
? Url.Action("Logo", "Kiosk")
|
||||
: null;
|
||||
ViewBag.WelcomeUrl = "/Kiosk/Welcome";
|
||||
await Task.CompletedTask;
|
||||
|
||||
// Pass the intake output setting so Terms.cshtml can show matching wording
|
||||
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
|
||||
p => p.CompanyId == company.Id && !p.IsDeleted, ignoreQueryFilters: true);
|
||||
ViewBag.KioskIntakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
|
||||
}
|
||||
|
||||
/// <summary>Loads the company from a session's CompanyId and populates ViewBag.</summary>
|
||||
@@ -687,5 +915,9 @@ public class KioskController : Controller
|
||||
|
||||
ViewBag.SessionToken = session.SessionToken;
|
||||
ViewBag.SessionType = session.SessionType;
|
||||
|
||||
// Reset to Welcome screen after 45 s of inactivity on any intake step.
|
||||
// The Welcome screen itself stays on indefinitely (no timeout override there).
|
||||
ViewBag.InactivityTimeoutMs = 45_000;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ public static class HelpKnowledgeBase
|
||||
- Job Priority Board → /JobsPriority
|
||||
- Online Payments → /Invoices/OnlinePayments
|
||||
- Gift Certificates → /GiftCertificates
|
||||
- Intake Sessions → /Kiosk/Intakes (walk-in and remote intake sessions submitted via the kiosk tablet)
|
||||
|
||||
**Inventory section:**
|
||||
- Catalog Items → /CatalogItems
|
||||
@@ -1265,6 +1266,60 @@ public static class HelpKnowledgeBase
|
||||
|
||||
---
|
||||
|
||||
## CUSTOMER INTAKE KIOSK
|
||||
|
||||
**Where:** Kiosk Setup → [/Kiosk/Activate](/Kiosk/Activate) | Intake Sessions → [/Kiosk/Intakes](/Kiosk/Intakes)
|
||||
|
||||
**What it does:** Lets walk-in customers fill out their own intake form on a front-desk tablet. On submission, a Customer record and either a Draft Quote or a Pending Job are auto-created (controlled by the Kiosk Output Setting), and staff receive an in-app notification. Also supports remote intake via email link so customers fill out the form on their own phone before arriving.
|
||||
|
||||
**Kiosk Output Setting (Company Settings → Kiosk tab):**
|
||||
- "Create a Quote" (default) — creates a Draft quote on submission; terms shown to customer say "subject to a formal quote." Best for shops that price after seeing the parts.
|
||||
- "Create a Job" — creates a Pending job on submission; terms say "team member will reach out about pricing." Best for shops that price on the spot.
|
||||
|
||||
**Setup (one-time per device):**
|
||||
1. Go to Settings → Kiosk Setup (or /Kiosk/Activate)
|
||||
2. Click Activate Kiosk — generates a secure activation token and sets a device cookie (365-day lifespan)
|
||||
3. On the tablet browser, navigate to /Kiosk/Welcome — the tablet is now in kiosk mode
|
||||
4. Add to Home Screen on iOS/Android for a full-screen PWA experience that preserves camera permissions
|
||||
|
||||
**Starting an in-person intake:**
|
||||
1. Customer approaches the tablet — it shows the Welcome screen with company logo and a green "Ready" dot
|
||||
2. Staff member clicks "Start Intake" on the Dashboard (Kiosk card)
|
||||
3. Tablet picks up the new session within 3 seconds and auto-navigates to the intake form
|
||||
4. Customer completes 3 steps: Contact info → Job description → Terms & drawn signature
|
||||
5. On submit: thank-you screen shown, kiosk returns to Welcome after 30 seconds
|
||||
6. If idle for 45 seconds during any intake step, the form resets to the Welcome screen automatically
|
||||
|
||||
**Sending a remote intake link:**
|
||||
- Click "Send Intake Link" on the Dashboard Kiosk card OR from /Kiosk/Intakes → Send Intake Link
|
||||
- Enter the customer's email → they receive a link to complete the form on their own device
|
||||
- Remote sessions use a checkbox agreement instead of a drawn signature
|
||||
|
||||
**What happens on submission:**
|
||||
- Customer is matched by email (first), then phone; if no match, a new non-commercial customer is created
|
||||
- A Draft Quote or Pending Job is created depending on the Kiosk Output Setting (see above)
|
||||
- SMS opt-in updates the customer record with NotifyBySms = true and a TCPA-compliant consent timestamp
|
||||
- In-app notification fires: "Walk-in Intake Submitted" (in-person) or "Remote Intake Submitted" (remote link) with a link to /Kiosk/Intakes
|
||||
|
||||
**Reviewing submissions (Intake Sessions page):**
|
||||
- Filter tabs: All / Submitted / Pending / Expired
|
||||
- Each row shows customer name, phone, email, job description snippet, session type badge, SMS opt-in icon
|
||||
- "View Quote" button → appears in Quote mode; opens the auto-created Draft quote for pricing and review
|
||||
- "View Job" button → appears in Job mode; opens the auto-created Pending job so staff can assign and progress it
|
||||
- "Customer" button → opens the matched/created customer record
|
||||
- If submission failed (e.g. seed data not run), the session is still marked Submitted but buttons won't appear — raw intake data is still visible so staff can create manually
|
||||
|
||||
**Dashboard Kiosk card:** Shows whether the kiosk is activated. Contains "Start Intake" (triggers in-person session) and "Send Intake Link" (opens email dialog) buttons. Both are disabled if the kiosk is not activated.
|
||||
|
||||
**Troubleshooting:**
|
||||
- "Connection issue — retrying…" on tablet: Wi-Fi problem; dot auto-recovers when connectivity returns
|
||||
- Tablet doesn't respond to Start Intake: waits up to 3 s; reload Welcome page if still stuck
|
||||
- No View Quote/Job button after submission: Seed Data not run — Platform Admin must run it from Platform Management → Seed Data
|
||||
- Signature pad not working: requires capacitive touch (finger or stylus); ensure "Request Desktop Site" is off in browser settings
|
||||
- AI quote times out on mobile: photos are auto-compressed; "Still analyzing…" message appears after 30 s; retry on stronger connection
|
||||
|
||||
---
|
||||
|
||||
## COMMON WORKFLOWS
|
||||
|
||||
**New company first-time setup:**
|
||||
@@ -1279,6 +1334,15 @@ public static class HelpKnowledgeBase
|
||||
**Prospect to customer:**
|
||||
Create Quote for prospect → Quote Approved → Convert Prospect to Customer → Convert Quote to Job
|
||||
|
||||
**Walk-in customer intake (kiosk — Quote mode):**
|
||||
Staff clicks "Start Intake" on Dashboard → tablet navigates to intake form within 3 s → customer fills out 3 steps (contact, job description, terms + signature) → system creates Customer + Draft Quote → "Walk-in Intake Submitted" notification fires → staff reviews at /Kiosk/Intakes → clicks "View Quote" to price and send the quote
|
||||
|
||||
**Walk-in customer intake (kiosk — Job mode):**
|
||||
Same flow as above, but system creates a Pending Job instead of a Quote → staff clicks "View Job" to assign a worker and progress the job through the workflow
|
||||
|
||||
**Remote intake (customer fills out before arriving):**
|
||||
Staff clicks "Send Intake Link" on Dashboard or Intakes page → enters customer email → customer receives link and completes form on their own device → same auto-create flow as in-person; notification reads "Remote Intake Submitted"
|
||||
|
||||
**Walk-in / phone quote (quick estimate):**
|
||||
Click the AI Quick Quote button (dark-blue floating button, bottom-right) → type description → AI returns price estimate → Save as draft under "Walk-In / Phone" → open the quote → reassign the Customer dropdown on Quote Details to the real customer record once you have their info
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ public class SubscriptionMiddleware
|
||||
"/Billing",
|
||||
"/api/",
|
||||
"/stripe/",
|
||||
"/hubs/",
|
||||
"/Kiosk/",
|
||||
"/Profile/Photo",
|
||||
"/CompanyLogo",
|
||||
"/AccountDataExport"
|
||||
|
||||
@@ -727,6 +727,12 @@ app.UseMiddleware<PowderCoating.Web.Middleware.MustChangePasswordMiddleware>();
|
||||
// Track authenticated user presence (throttled, in-memory)
|
||||
app.UseMiddleware<PowderCoating.Web.Middleware.OnlineUserMiddleware>();
|
||||
|
||||
// Kiosk intake steps use /Kiosk/Intake/{token}/{action} so the token is a path segment
|
||||
app.MapControllerRoute(
|
||||
name: "kiosk_intake",
|
||||
pattern: "Kiosk/Intake/{token}/{action}",
|
||||
defaults: new { controller = "Kiosk" });
|
||||
|
||||
app.MapControllerRoute(
|
||||
name: "default",
|
||||
pattern: "{controller=Home}/{action=Index}/{id?}");
|
||||
|
||||
@@ -72,6 +72,7 @@ public class InAppNotificationService : IInAppNotificationService
|
||||
message = notification.Message,
|
||||
link = notification.Link,
|
||||
notificationType = notification.NotificationType,
|
||||
customerId = notification.CustomerId,
|
||||
createdAt = now.ToString("o")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<option value="data-retention">Data Retention</option>
|
||||
<option value="data-lookups">Data Lookups</option>
|
||||
<option value="pdf-templates">PDF Templates</option>
|
||||
<option value="kiosk">Kiosk</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -100,6 +101,11 @@
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="kiosk-tab" data-bs-toggle="tab" data-bs-target="#kiosk" type="button" role="tab">
|
||||
<i class="bi bi-tablet"></i> Kiosk
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Tabs Content -->
|
||||
@@ -1978,6 +1984,67 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Kiosk Tab -->
|
||||
<div class="tab-pane fade" id="kiosk" role="tabpanel">
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="bi bi-tablet me-2"></i>Customer Intake Kiosk</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<h6 class="fw-semibold mb-1">Intake Output</h6>
|
||||
<p class="text-muted small mb-3">
|
||||
When a customer completes the intake form, what should be created in the system?
|
||||
</p>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100 border @(Model.Preferences?.KioskIntakeOutput == "Job" ? "" : "border-primary bg-primary-subtle")"
|
||||
id="kioskOutputQuoteCard" style="cursor:pointer;" onclick="selectKioskOutput('Quote')">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="radio" name="kioskOutput" id="kioskOutputQuote"
|
||||
value="Quote" @(Model.Preferences?.KioskIntakeOutput != "Job" ? "checked" : "") />
|
||||
</div>
|
||||
<h6 class="mb-0 fw-semibold"><i class="bi bi-file-earmark-text me-1 text-primary"></i>Create a Quote</h6>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">
|
||||
A draft quote is created and reviewed by staff before work begins.
|
||||
Best for shops that price after seeing the parts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100 border @(Model.Preferences?.KioskIntakeOutput == "Job" ? "border-success bg-success-subtle" : "")"
|
||||
id="kioskOutputJobCard" style="cursor:pointer;" onclick="selectKioskOutput('Job')">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="radio" name="kioskOutput" id="kioskOutputJob"
|
||||
value="Job" @(Model.Preferences?.KioskIntakeOutput == "Job" ? "checked" : "") />
|
||||
</div>
|
||||
<h6 class="mb-0 fw-semibold"><i class="bi bi-briefcase me-1 text-success"></i>Create a Job</h6>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">
|
||||
A job is created immediately on submission.
|
||||
Best for shops that price on the spot and want the work order ready right away.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-primary" onclick="saveKioskSettings()">
|
||||
<i class="bi bi-floppy me-1"></i> Save Kiosk Settings
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3248,12 +3315,41 @@
|
||||
else showError(data.message);
|
||||
}
|
||||
|
||||
function selectKioskOutput(value) {
|
||||
document.getElementById('kioskOutputQuote').checked = value === 'Quote';
|
||||
document.getElementById('kioskOutputJob').checked = value === 'Job';
|
||||
|
||||
document.getElementById('kioskOutputQuoteCard').classList.toggle('border-primary', value === 'Quote');
|
||||
document.getElementById('kioskOutputQuoteCard').classList.toggle('bg-primary-subtle', value === 'Quote');
|
||||
document.getElementById('kioskOutputJobCard').classList.toggle('border-success', value === 'Job');
|
||||
document.getElementById('kioskOutputJobCard').classList.toggle('bg-success-subtle', value === 'Job');
|
||||
}
|
||||
|
||||
async function saveKioskSettings() {
|
||||
const value = document.querySelector('input[name="kioskOutput"]:checked')?.value ?? 'Quote';
|
||||
const resp = await fetch('/CompanySettings/UpdateKioskSettings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': $('input[name="__RequestVerificationToken"]').val()
|
||||
},
|
||||
body: JSON.stringify({ kioskIntakeOutput: value })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) showSuccess(data.message);
|
||||
else showError(data.message);
|
||||
}
|
||||
|
||||
// Auto-open online-payments tab if redirected with ?tab=online-payments
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('tab') === 'online-payments') {
|
||||
const btn = document.querySelector('[data-bs-target="#online-payments"]');
|
||||
if (btn) new bootstrap.Tab(btn).show();
|
||||
}
|
||||
if (urlParams.get('tab') === 'kiosk') {
|
||||
const btn = document.querySelector('[data-bs-target="#kiosk"]');
|
||||
if (btn) new bootstrap.Tab(btn).show();
|
||||
}
|
||||
</script>
|
||||
|
||||
}
|
||||
|
||||
@@ -173,9 +173,11 @@
|
||||
<i class="bi bi-envelope-slash me-1"></i>Email off
|
||||
</span>
|
||||
}
|
||||
<span id="sms-status-section">
|
||||
@if (Model.NotifyBySms)
|
||||
{
|
||||
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25">
|
||||
<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25"
|
||||
title="@(Model.SmsConsentedAt.HasValue ? "Consented " + Model.SmsConsentedAt.Value.ToLocalTime().ToString("MM/dd/yyyy") : "")">
|
||||
<i class="bi bi-chat-fill me-1"></i>SMS on
|
||||
</span>
|
||||
}
|
||||
@@ -184,7 +186,22 @@
|
||||
<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>
|
||||
<button type="button" id="btnGetSmsConsent"
|
||||
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
|
||||
</button>
|
||||
<button type="button" id="btnCancelSmsConsent"
|
||||
class="badge bg-warning bg-opacity-10 text-warning border border-warning border-opacity-25 border-0 d-none"
|
||||
style="cursor:pointer;"
|
||||
title="Cancel the pending kiosk consent request"
|
||||
onclick="cancelSmsConsent()">
|
||||
<i class="bi bi-x-circle me-1"></i>Cancel Consent
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -543,3 +560,8 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/customer-details.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
@{
|
||||
ViewData["Title"] = "Customer Intake Kiosk";
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
|
||||
<li class="breadcrumb-item active">Customer Intake Kiosk</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-9">
|
||||
|
||||
<section id="overview" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-info-circle text-primary me-2"></i>Overview
|
||||
</h2>
|
||||
<p>
|
||||
The Customer Intake Kiosk lets walk-in customers fill out their own intake form on a front-desk tablet
|
||||
— no staff assistance required. When they're done, a <strong>customer record</strong> is automatically
|
||||
created (or matched to an existing one), a <strong>Draft Quote or Pending Job</strong> is created
|
||||
depending on your setting, and your team receives an in-app notification.
|
||||
</p>
|
||||
<p>
|
||||
The kiosk runs as a browser page (optimised for iPad and Android tablets) and can also send a
|
||||
<strong>remote link</strong> so customers fill out the form on their own phone before they arrive.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="setup" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-gear text-primary me-2"></i>Setting Up the Kiosk
|
||||
</h2>
|
||||
<ol>
|
||||
<li class="mb-2">
|
||||
Go to <strong>Settings → Kiosk Setup</strong> (or <a href="/Kiosk/Activate">/Kiosk/Activate</a>).
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
Click <strong>Activate Kiosk</strong>. This generates a unique activation token for your company
|
||||
and sets a secure cookie on the current device.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
On the tablet, open a browser and navigate to <code>/Kiosk/Welcome</code>. You'll see your
|
||||
company logo and a "Ready" indicator — the tablet is now in kiosk mode.
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong>Add to Home Screen</strong> on iOS/Android for a full-screen, app-like experience that
|
||||
also preserves camera permissions between sessions.
|
||||
</li>
|
||||
</ol>
|
||||
<div class="alert alert-info alert-permanent">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
The kiosk cookie is device-specific and lasts 365 days. If you swap tablets or clear the browser,
|
||||
go back to Kiosk Setup and activate again.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="starting" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-play-circle text-primary me-2"></i>Starting an Intake Session
|
||||
</h2>
|
||||
<p>There are two ways to start an intake:</p>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-3 mb-2">In-Person (tablet at front desk)</h3>
|
||||
<ol>
|
||||
<li class="mb-1">The tablet sits on the Welcome screen — the customer sees your logo and a "Ready" status dot.</li>
|
||||
<li class="mb-1">A staff member clicks <strong>Start Intake</strong> on the Dashboard (in the Kiosk card).</li>
|
||||
<li class="mb-1">The tablet detects the new session within 3 seconds and automatically navigates to the intake form.</li>
|
||||
<li class="mb-1">The customer fills out <strong>3 steps</strong>: Contact info → Job description → Terms & signature.</li>
|
||||
<li class="mb-1">On Submit, the kiosk shows a thank-you screen and returns to Welcome after 30 seconds.</li>
|
||||
</ol>
|
||||
<div class="alert alert-warning alert-permanent mt-2">
|
||||
<i class="bi bi-clock me-2"></i>
|
||||
If the customer leaves the form untouched for <strong>45 seconds</strong>, it automatically
|
||||
resets to the Welcome screen.
|
||||
</div>
|
||||
|
||||
<h3 class="h6 fw-semibold mt-4 mb-2">Remote Link (customer fills out on their phone)</h3>
|
||||
<ol>
|
||||
<li class="mb-1">Go to <a href="/Kiosk/Intakes">Kiosk → Customer Intakes</a> and click <strong>Send Intake Link</strong>.</li>
|
||||
<li class="mb-1">Or use the <strong>Send Intake Link</strong> button on the Dashboard Kiosk card.</li>
|
||||
<li class="mb-1">Enter the customer's email address and send.</li>
|
||||
<li class="mb-1">The customer receives an email with a secure link and completes the same 3-step form on their own device.</li>
|
||||
<li class="mb-1">Remote sessions don't require a drawn signature — a checkbox agreement is used instead.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section id="output-setting" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-sliders text-primary me-2"></i>Kiosk Output Setting
|
||||
</h2>
|
||||
<p>
|
||||
You can control what gets created when a customer submits the intake form.
|
||||
Go to <a href="/CompanySettings?tab=kiosk">Company Settings → Kiosk</a> and choose:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Create a Quote</strong> (default) — a Draft quote is created for staff to review and price
|
||||
before work begins. The terms shown to the customer will say "subject to a formal quote." Use this
|
||||
if you price after seeing the parts.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Create a Job</strong> — a Pending job is created immediately. The terms will say "a team
|
||||
member will reach out about pricing." Use this if you price on the spot and want the work order
|
||||
ready right away.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="what-happens" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-arrow-right-circle text-primary me-2"></i>What Happens on Submission
|
||||
</h2>
|
||||
<p>When a customer submits their intake form, the system automatically:</p>
|
||||
<ul>
|
||||
<li><strong>Matches or creates a Customer</strong> — searches by email first, then phone. If no match, a new non-commercial customer record is created.</li>
|
||||
<li>
|
||||
<strong>Creates a Draft Quote or Pending Job</strong> — depending on your
|
||||
<a href="/CompanySettings?tab=kiosk">Kiosk Output Setting</a>. Quote mode creates a Draft quote
|
||||
(Normal priority); Job mode creates a Pending job with the customer's description and intake source
|
||||
in Special Instructions.
|
||||
</li>
|
||||
<li><strong>Applies SMS consent</strong> — if the customer opted in, their customer record is updated with <code>NotifyBySms = true</code> and the consent timestamp (TCPA-compliant).</li>
|
||||
<li>
|
||||
<strong>Fires an in-app notification</strong> — your team's notification bell shows
|
||||
"Walk-in Intake Submitted" (or "Remote Intake Submitted" for remote sessions) with a link to
|
||||
the Intakes page.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="reviewing" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-clipboard-check text-primary me-2"></i>Reviewing Submissions (Staff)
|
||||
</h2>
|
||||
<p>
|
||||
Go to <a href="/Kiosk/Intakes">Operations → Intake Sessions</a> to see all sessions.
|
||||
Filter by <strong>Submitted</strong>, <strong>Pending</strong>, or <strong>Expired</strong>.
|
||||
</p>
|
||||
<p>Each row shows:</p>
|
||||
<ul>
|
||||
<li>Customer name, phone, and email</li>
|
||||
<li>Job description snippet</li>
|
||||
<li>Session type (In-Person or Remote) and status badge</li>
|
||||
<li>SMS opt-in indicator</li>
|
||||
<li><strong>View Quote</strong> button — appears when the kiosk is set to Quote mode; opens the auto-created draft quote</li>
|
||||
<li><strong>View Job</strong> button — appears when the kiosk is set to Job mode; opens the auto-created job</li>
|
||||
<li><strong>Customer</strong> button — opens the matched or created customer record</li>
|
||||
</ul>
|
||||
<div class="alert alert-info alert-permanent">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
If submission failed (e.g. a configuration issue), the session is still marked Submitted but the
|
||||
action buttons won't appear. The raw intake data (name, phone, description) is still
|
||||
visible so staff can create the record manually.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="troubleshooting" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-exclamation-triangle text-primary me-2"></i>Troubleshooting
|
||||
</h2>
|
||||
<dl>
|
||||
<dt>Kiosk Welcome screen shows "Connection issue — retrying…"</dt>
|
||||
<dd class="mb-3">The tablet can't reach the server. Check the tablet's Wi-Fi connection. Once connectivity is restored the status dot automatically turns green — no refresh needed.</dd>
|
||||
|
||||
<dt>Kiosk doesn't respond when staff clicks Start Intake</dt>
|
||||
<dd class="mb-3">The tablet polls every 3 seconds. Wait up to 3 seconds after clicking Start Intake. If it still doesn't respond, reload the Welcome page on the tablet. Make sure the tablet is on the same domain as the server (use HTTPS).</dd>
|
||||
|
||||
<dt>The tablet shows the wrong company logo or no logo</dt>
|
||||
<dd class="mb-3">Upload your company logo at Settings → Company Settings → Logo. The kiosk reads your logo directly — no separate kiosk logo setting is needed.</dd>
|
||||
|
||||
<dt>Signature pad doesn't work on the tablet</dt>
|
||||
<dd class="mb-3">Use a capacitive stylus or fingertip — the signature pad requires touch input. Make sure the browser isn't in desktop mode (check "Request Desktop Site" is off). The signature is only required for In-Person sessions.</dd>
|
||||
|
||||
<dt>Submission fails — no job or customer created</dt>
|
||||
<dd class="mb-3">This usually means Seed Data hasn't been run for your company. Ask your administrator to go to Platform Management → Seed Data and run the seed. This creates the required job status and priority lookup rows.</dd>
|
||||
|
||||
<dt>AI quote on the quote wizard times out on mobile</dt>
|
||||
<dd class="mb-3">Photos are automatically compressed before upload. If it still times out, your connection may be slow — the spinner will say "Still analyzing…" if it's taking longer than 30 seconds. Try again on a stronger connection.</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3">
|
||||
@await Html.PartialAsync("_HelpNav")
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase">
|
||||
On This Page
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<nav class="nav flex-column small">
|
||||
<a class="nav-link py-1 px-3" href="#overview">Overview</a>
|
||||
<a class="nav-link py-1 px-3" href="#setup">Setting Up the Kiosk</a>
|
||||
<a class="nav-link py-1 px-3" href="#starting">Starting an Intake</a>
|
||||
<a class="nav-link py-1 px-3" href="#output-setting">Kiosk Output Setting</a>
|
||||
<a class="nav-link py-1 px-3" href="#what-happens">What Happens on Submission</a>
|
||||
<a class="nav-link py-1 px-3" href="#reviewing">Reviewing Submissions</a>
|
||||
<a class="nav-link py-1 px-3" href="#troubleshooting">Troubleshooting</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,6 +25,10 @@
|
||||
asp-controller="Help" asp-action="Jobs">
|
||||
<i class="bi bi-briefcase"></i> Jobs
|
||||
</a>
|
||||
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "CustomerIntakeKiosk" ? "active fw-semibold text-primary" : "text-body")"
|
||||
asp-controller="Help" asp-action="CustomerIntakeKiosk">
|
||||
<i class="bi bi-tablet"></i> Customer Intake Kiosk
|
||||
</a>
|
||||
<a class="nav-link py-2 px-3 d-flex align-items-center gap-2 @(currentAction == "Quotes" ? "active fw-semibold text-primary" : "text-body")"
|
||||
asp-controller="Help" asp-action="Quotes">
|
||||
<i class="bi bi-file-earmark-text"></i> Quotes
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
@{
|
||||
Layout = "~/Views/Shared/_KioskLayout.cshtml";
|
||||
ViewData["Title"] = "Terms & Consent";
|
||||
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
|
||||
bool isInPerson = ViewBag.IsInPerson as bool? ?? false;
|
||||
var token = ViewBag.SessionToken as Guid? ?? Guid.Empty;
|
||||
bool isInPerson = ViewBag.IsInPerson as bool? ?? false;
|
||||
bool quoteFirst = !string.Equals(ViewBag.KioskIntakeOutput as string, "Job", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
<div class="kiosk-card">
|
||||
@@ -25,10 +26,20 @@
|
||||
have authority to authorize work on them. You release the shop from liability for
|
||||
pre-existing damage, hidden defects, or items left unclaimed after 30 days.
|
||||
</p>
|
||||
<p>
|
||||
Final pricing is subject to a formal quote. Work will not begin until you approve
|
||||
the quoted amount. Payment is due upon pickup unless otherwise agreed in writing.
|
||||
</p>
|
||||
@if (quoteFirst)
|
||||
{
|
||||
<p>
|
||||
Final pricing is subject to a formal quote. Work will not begin until you approve
|
||||
the quoted amount. Payment is due upon pickup unless otherwise agreed in writing.
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>
|
||||
A team member will review your intake and reach out about pricing before work begins.
|
||||
Payment is due upon pickup unless otherwise agreed in writing.
|
||||
</p>
|
||||
}
|
||||
<p class="mb-0">
|
||||
You agree to comply with all pickup and payment terms provided by the shop.
|
||||
</p>
|
||||
@@ -90,9 +101,7 @@
|
||||
@if (isInPerson)
|
||||
{
|
||||
@section Scripts {
|
||||
<script src="https://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js"
|
||||
integrity="sha384-bQMMRVcRi5vEIBLKnB4FY7tBOA9k/Qvd/9zSWMNO4h0zfB2qLj4DV2R/JyPAbF3"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="~/lib/signature-pad/signature_pad.umd.min.js"></script>
|
||||
<script src="~/js/kiosk-terms.js"></script>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,12 @@
|
||||
<i class="bi bi-briefcase me-1"></i>View Job
|
||||
</a>
|
||||
}
|
||||
@if (s.LinkedQuoteId.HasValue)
|
||||
{
|
||||
<a href="/Quotes/Details/@s.LinkedQuoteId" class="btn btn-sm btn-outline-info me-1">
|
||||
<i class="bi bi-file-earmark-text me-1"></i>View Quote
|
||||
</a>
|
||||
}
|
||||
@if (s.LinkedCustomerId.HasValue)
|
||||
{
|
||||
<a href="/Customers/Details/@s.LinkedCustomerId" class="btn btn-sm btn-outline-primary">
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
@model int
|
||||
@{
|
||||
Layout = "~/Views/Shared/_KioskLayout.cshtml";
|
||||
ViewData["Title"] = "SMS Consent";
|
||||
string customerName = ViewBag.CustomerName as string ?? "Customer";
|
||||
}
|
||||
|
||||
<div class="kiosk-card">
|
||||
<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="/Kiosk/SmsConsent/@Model">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="agreed" value="true" />
|
||||
|
||||
<div class="kiosk-terms-scroll mb-4">
|
||||
<strong>SMS Consent & Opt-In</strong>
|
||||
<p class="mt-2">
|
||||
By tapping <em>I Agree</em> below, <strong>@customerName</strong> consents to receive
|
||||
SMS text messages from @(ViewBag.CompanyName ?? "this shop") regarding order status
|
||||
updates, pickup notifications, and other information related to your powder coating
|
||||
services.
|
||||
</p>
|
||||
<p>
|
||||
Message frequency varies. Message and data rates may apply.
|
||||
You may opt out at any time by replying <strong>STOP</strong> to any message.
|
||||
Reply <strong>HELP</strong> for assistance.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
Your mobile number will not be shared with third parties or used for marketing
|
||||
unrelated to your orders.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-3">
|
||||
<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>
|
||||
<button type="submit" class="btn btn-success kiosk-btn">
|
||||
<i class="bi bi-check-circle me-2"></i> I Agree
|
||||
</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>
|
||||
@@ -23,8 +23,8 @@
|
||||
|
||||
<div class="kiosk-idle-indicator">
|
||||
<span id="kiosk-conn-dot" style="display:inline-block;width:10px;height:10px;
|
||||
border-radius:50%;background:#16a34a;margin-right:6px;transition:background 0.3s;"></span>
|
||||
Ready
|
||||
border-radius:50%;background:#94a3b8;margin-right:6px;transition:background 0.3s;"></span>
|
||||
<span id="kiosk-conn-label">Connecting…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -59,16 +59,19 @@
|
||||
|
||||
</div>
|
||||
|
||||
@* Inactivity timer — redirect to Welcome after 5 minutes of no input *@
|
||||
@* Inactivity timer — redirect to Welcome when idle too long.
|
||||
Intake steps set ViewBag.InactivityTimeoutMs = 45000 (45 s).
|
||||
Welcome screen keeps the default 5-minute timeout. *@
|
||||
@{
|
||||
bool showInactivityTimer = (bool)(ViewBag.ShowInactivityTimer ?? true);
|
||||
string welcomeUrl = ViewBag.WelcomeUrl as string ?? "/Kiosk/Welcome";
|
||||
int inactivityMs = ViewBag.InactivityTimeoutMs as int? ?? (5 * 60 * 1000);
|
||||
}
|
||||
@if (showInactivityTimer)
|
||||
{
|
||||
<script>
|
||||
(function () {
|
||||
var TIMEOUT_MS = 5 * 60 * 1000;
|
||||
var TIMEOUT_MS = @inactivityMs;
|
||||
var timer;
|
||||
function reset() {
|
||||
clearTimeout(timer);
|
||||
@@ -85,7 +88,6 @@
|
||||
}
|
||||
|
||||
<script src="~/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="~/lib/microsoft/signalr/dist/browser/signalr.min.js"></script>
|
||||
@await RenderSectionAsync("Scripts", required: false)
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1914,7 +1914,8 @@
|
||||
const icons = {
|
||||
QuoteApproved: { icon: 'bi-check-circle-fill', cls: 'success', title: 'Quote Approved' },
|
||||
QuoteDeclined: { icon: 'bi-x-circle-fill', cls: 'danger', title: 'Quote Declined' },
|
||||
InvoicePaid: { icon: 'bi-cash-coin', cls: 'primary', title: 'Payment Received' }
|
||||
InvoicePaid: { icon: 'bi-cash-coin', cls: 'primary', title: 'Payment Received' },
|
||||
KioskConsent: { icon: 'bi-chat-fill', cls: 'success', title: 'SMS Consent' }
|
||||
};
|
||||
const t = icons[data.notificationType] || { icon: 'bi-bell', cls: 'info', title: 'Notification' };
|
||||
toastr[t.cls === 'danger' ? 'warning' : t.cls === 'primary' ? 'info' : 'success'](
|
||||
@@ -1922,6 +1923,12 @@
|
||||
`<i class="bi ${t.icon} me-1"></i>${t.title}`,
|
||||
{ timeOut: 10000, extendedTimeOut: 3000, closeButton: true, enableHtml: true }
|
||||
);
|
||||
if (data.notificationType === 'KioskConsent' && data.customerId) {
|
||||
const path = window.location.pathname.toLowerCase();
|
||||
if (path === `/customers/details/${data.customerId}`) {
|
||||
window.updateCustomerSmsStatus?.();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
connection.start().catch(err => console.warn('SignalR connection failed:', err));
|
||||
@@ -2101,8 +2108,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,49 @@
|
||||
"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');
|
||||
document.getElementById('btnGetSmsConsent')?.classList.add('d-none');
|
||||
document.getElementById('btnCancelSmsConsent')?.classList.remove('d-none');
|
||||
} else {
|
||||
toastr.warning(data.message || 'Could not send consent to kiosk.');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelSmsConsent() {
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch('/Kiosk/CancelSmsConsent', {
|
||||
method: 'POST',
|
||||
headers: { 'RequestVerificationToken': tok }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
toastr.info('Consent request cancelled — kiosk is free.');
|
||||
document.getElementById('btnCancelSmsConsent')?.classList.add('d-none');
|
||||
document.getElementById('btnGetSmsConsent')?.classList.remove('d-none');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
window.updateCustomerSmsStatus = function () {
|
||||
const section = document.getElementById('sms-status-section');
|
||||
if (!section) return;
|
||||
const today = new Date().toLocaleDateString('en-US', { month: '2-digit', day: '2-digit', year: 'numeric' });
|
||||
section.innerHTML = `<span class="badge bg-success bg-opacity-10 text-success border border-success border-opacity-25"
|
||||
title="Consented ${today}">
|
||||
<i class="bi bi-chat-fill me-1"></i>SMS on
|
||||
</span>`;
|
||||
};
|
||||
@@ -1166,16 +1166,50 @@ function aiHandleDrop(event) {
|
||||
Array.from(event.dataTransfer.files).forEach(aiUploadFile);
|
||||
}
|
||||
|
||||
// Resize + recompress an image file before upload so phone photos (5-15 MB)
|
||||
// don't saturate mobile upload bandwidth or slow down Anthropic processing.
|
||||
// Max 1200px on the long edge, JPEG at 85% quality — ~150-250 KB typical output.
|
||||
// Non-image files and GIFs are returned unchanged.
|
||||
async function aiCompressImage(file, maxPx = 1200, quality = 0.85) {
|
||||
if (!file.type.startsWith('image/') || file.type === 'image/gif') return file;
|
||||
return new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const scale = Math.min(1, maxPx / Math.max(img.width, img.height));
|
||||
const w = Math.round(img.width * scale);
|
||||
const h = Math.round(img.height * scale);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
|
||||
canvas.toBlob(blob => {
|
||||
if (!blob || blob.size >= file.size) { resolve(file); return; }
|
||||
resolve(new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg' }));
|
||||
}, 'image/jpeg', quality);
|
||||
};
|
||||
img.onerror = () => resolve(file);
|
||||
img.src = e.target.result;
|
||||
};
|
||||
reader.onerror = () => resolve(file);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function aiUploadFile(file) {
|
||||
// Read as data: URL — blob: URLs are blocked by CSP; data: is explicitly allowed
|
||||
// Compress before uploading — full-res phone photos slow upload + Anthropic API
|
||||
const compressed = await aiCompressImage(file);
|
||||
|
||||
// Read compressed bytes for the thumbnail preview (blob: URLs blocked by CSP)
|
||||
const previewUrl = await new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve(e.target.result);
|
||||
reader.onerror = () => resolve('');
|
||||
reader.readAsDataURL(file);
|
||||
reader.readAsDataURL(compressed);
|
||||
});
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('file', compressed);
|
||||
formData.append('__RequestVerificationToken',
|
||||
document.querySelector('input[name="__RequestVerificationToken"]')?.value || '');
|
||||
|
||||
@@ -1278,15 +1312,27 @@ async function aiAnalyze() {
|
||||
};
|
||||
|
||||
const analyzeUrl = pageMeta.aiAnalyzeUrl || '/Quotes/AiAnalyzeItem';
|
||||
const controller = new AbortController();
|
||||
// Abort after 120 s — server-side Anthropic timeout is 60 s per attempt with retries;
|
||||
// 120 s gives room for one retry plus network round-trip on a slow mobile connection.
|
||||
const hardTimeout = setTimeout(() => controller.abort(), 120_000);
|
||||
// After 30 s without a response, update the spinner text so the user knows it's working.
|
||||
const slowWarning = setTimeout(() => {
|
||||
const t = document.getElementById('ai_loadingText');
|
||||
if (t) t.textContent = 'Still analyzing… this can take a minute on mobile connections.';
|
||||
}, 30_000);
|
||||
try {
|
||||
const resp = await fetch(analyzeUrl, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
clearTimeout(hardTimeout);
|
||||
clearTimeout(slowWarning);
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 401 || resp.status === 302 || resp.redirected) {
|
||||
throw new Error('Your session has expired. Please refresh the page and sign in again.');
|
||||
@@ -1301,9 +1347,15 @@ async function aiAnalyze() {
|
||||
const result = await resp.json();
|
||||
aiHandleResult(result);
|
||||
} catch (err) {
|
||||
clearTimeout(hardTimeout);
|
||||
clearTimeout(slowWarning);
|
||||
console.error('AI analyze error:', err);
|
||||
aiSetLoading(false);
|
||||
aiShowError(err.message);
|
||||
if (err.name === 'AbortError') {
|
||||
aiShowError('The request timed out — your connection may be slow. Please try again.');
|
||||
} else {
|
||||
aiShowError(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1340,15 +1392,24 @@ async function aiSendFollowup() {
|
||||
};
|
||||
|
||||
const analyzeUrl = pageMeta.aiAnalyzeUrl || '/Quotes/AiAnalyzeItem';
|
||||
const controller2 = new AbortController();
|
||||
const hardTimeout2 = setTimeout(() => controller2.abort(), 120_000);
|
||||
const slowWarning2 = setTimeout(() => {
|
||||
const t = document.getElementById('ai_loadingText');
|
||||
if (t) t.textContent = 'Still analyzing… this can take a minute on mobile connections.';
|
||||
}, 30_000);
|
||||
try {
|
||||
const resp = await fetch(analyzeUrl, {
|
||||
method: 'POST',
|
||||
signal: controller2.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
clearTimeout(hardTimeout2);
|
||||
clearTimeout(slowWarning2);
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 401 || resp.status === 302 || resp.redirected) {
|
||||
throw new Error('Your session has expired. Please refresh the page and sign in again.');
|
||||
@@ -1362,9 +1423,15 @@ async function aiSendFollowup() {
|
||||
const result = await resp.json();
|
||||
aiHandleResult(result);
|
||||
} catch (err) {
|
||||
clearTimeout(hardTimeout2);
|
||||
clearTimeout(slowWarning2);
|
||||
console.error('AI follow-up error:', err);
|
||||
aiSetLoading(false);
|
||||
aiShowError(err.message);
|
||||
if (err.name === 'AbortError') {
|
||||
aiShowError('The request timed out — your connection may be slow. Please try again.');
|
||||
} else {
|
||||
aiShowError(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1445,12 +1512,14 @@ function aiRemoveTag(tag) {
|
||||
}
|
||||
|
||||
function aiSetLoading(isLoading) {
|
||||
const btn = document.getElementById('ai_analyzeBtn');
|
||||
const btn = document.getElementById('ai_analyzeBtn');
|
||||
const spinner = document.getElementById('ai_loadingSpinner');
|
||||
const text = document.getElementById('ai_loadingText');
|
||||
const text = document.getElementById('ai_loadingText');
|
||||
if (btn) btn.disabled = isLoading;
|
||||
spinner?.classList.toggle('d-none', !isLoading);
|
||||
text?.classList.toggle('d-none', !isLoading);
|
||||
// Reset text so a retry after the slow-connection warning shows the default message
|
||||
if (!isLoading && text) text.textContent = 'Analyzing photos, please wait…';
|
||||
}
|
||||
|
||||
function aiShowError(message) {
|
||||
|
||||
@@ -3,46 +3,59 @@
|
||||
(function () {
|
||||
// ── Signature pad (InPerson sessions only) ─────────────────────────────────
|
||||
const canvas = document.getElementById("signatureCanvas");
|
||||
if (canvas) {
|
||||
const pad = new SignaturePad(canvas, { penColor: "#1e293b" });
|
||||
if (!canvas) return;
|
||||
|
||||
// Scale canvas to device pixel ratio for crisp rendering on high-DPI tablets
|
||||
function resizeCanvas() {
|
||||
const ratio = Math.max(window.devicePixelRatio || 1, 1);
|
||||
canvas.width = canvas.offsetWidth * ratio;
|
||||
canvas.height = canvas.offsetHeight * ratio;
|
||||
canvas.getContext("2d").scale(ratio, ratio);
|
||||
pad.clear();
|
||||
}
|
||||
resizeCanvas();
|
||||
window.addEventListener("resize", resizeCanvas);
|
||||
if (typeof SignaturePad === "undefined") {
|
||||
console.error("signature_pad failed to load — signature capture unavailable");
|
||||
canvas.parentElement.insertAdjacentHTML("beforeend",
|
||||
'<p class="text-danger small mt-1">Signature pad failed to load. Please refresh the page.</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show visual feedback when the canvas has been signed
|
||||
pad.addEventListener("endStroke", function () {
|
||||
canvas.classList.add("signed");
|
||||
});
|
||||
const pad = new SignaturePad(canvas, { penColor: "#1e293b" });
|
||||
|
||||
document.getElementById("clearSignatureBtn")?.addEventListener("click", function () {
|
||||
pad.clear();
|
||||
canvas.classList.remove("signed");
|
||||
});
|
||||
// Scale canvas to device pixel ratio — must run after layout so offsetWidth is non-zero.
|
||||
// requestAnimationFrame ensures the browser has finished its first layout pass.
|
||||
function resizeCanvas() {
|
||||
const ratio = Math.max(window.devicePixelRatio || 1, 1);
|
||||
const w = canvas.offsetWidth;
|
||||
const h = canvas.offsetHeight;
|
||||
if (w === 0 || h === 0) return; // layout not ready yet; resize event will retry
|
||||
canvas.width = w * ratio;
|
||||
canvas.height = h * ratio;
|
||||
canvas.getContext("2d").scale(ratio, ratio);
|
||||
pad.clear();
|
||||
}
|
||||
|
||||
// On submit: write base64 PNG to the hidden input
|
||||
const form = document.getElementById("termsForm");
|
||||
if (form) {
|
||||
form.addEventListener("submit", function (e) {
|
||||
const hiddenInput = document.getElementById("SignatureDataBase64");
|
||||
if (hiddenInput) {
|
||||
if (pad.isEmpty()) {
|
||||
e.preventDefault();
|
||||
const msg = document.getElementById("signatureError");
|
||||
if (msg) msg.classList.remove("d-none");
|
||||
canvas.classList.add("is-invalid");
|
||||
return;
|
||||
}
|
||||
hiddenInput.value = pad.toDataURL("image/png");
|
||||
requestAnimationFrame(resizeCanvas);
|
||||
window.addEventListener("resize", resizeCanvas);
|
||||
|
||||
// Visual feedback when the canvas has been signed
|
||||
pad.addEventListener("endStroke", function () {
|
||||
canvas.classList.add("signed");
|
||||
});
|
||||
|
||||
document.getElementById("clearSignatureBtn")?.addEventListener("click", function () {
|
||||
pad.clear();
|
||||
canvas.classList.remove("signed");
|
||||
});
|
||||
|
||||
// On submit: write base64 PNG to the hidden input
|
||||
const form = document.getElementById("termsForm");
|
||||
if (form) {
|
||||
form.addEventListener("submit", function (e) {
|
||||
const hiddenInput = document.getElementById("SignatureDataBase64");
|
||||
if (hiddenInput) {
|
||||
if (pad.isEmpty()) {
|
||||
e.preventDefault();
|
||||
const msg = document.getElementById("signatureError");
|
||||
if (msg) msg.classList.remove("d-none");
|
||||
canvas.classList.add("is-invalid");
|
||||
canvas.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
hiddenInput.value = pad.toDataURL("image/png");
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
"use strict";
|
||||
|
||||
// Polls /Kiosk/PollSession every 3 seconds and navigates when staff triggers an intake.
|
||||
// SignalR was replaced with polling because Azure App Service's ingress proxy cancels
|
||||
// anonymous WebSocket and SSE handshakes before the SignalR protocol exchange completes.
|
||||
(function () {
|
||||
const el = document.getElementById("kiosk-welcome-root");
|
||||
if (!el) return;
|
||||
|
||||
const companyId = el.dataset.companyId;
|
||||
if (!companyId) return;
|
||||
const dot = document.getElementById("kiosk-conn-dot");
|
||||
const label = document.getElementById("kiosk-conn-label");
|
||||
|
||||
const connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(`/hubs/kiosk?companyId=${companyId}`)
|
||||
.withAutomaticReconnect([2000, 5000, 10000, 30000])
|
||||
.configureLogging(signalR.LogLevel.Warning)
|
||||
.build();
|
||||
|
||||
connection.on("StartIntake", function (sessionToken) {
|
||||
window.location.href = `/Kiosk/Intake/${sessionToken}/Contact`;
|
||||
});
|
||||
|
||||
async function startConnection() {
|
||||
try {
|
||||
await connection.start();
|
||||
} catch (err) {
|
||||
console.warn("Kiosk SignalR connect failed, retrying in 10s...", err);
|
||||
setTimeout(startConnection, 10000);
|
||||
}
|
||||
function setStatus(color, text) {
|
||||
if (dot) dot.style.background = color;
|
||||
if (label) label.textContent = text;
|
||||
}
|
||||
|
||||
startConnection();
|
||||
setStatus("#94a3b8", "Connecting…");
|
||||
|
||||
// Show connection status indicator
|
||||
connection.onreconnecting(() => {
|
||||
const dot = document.getElementById("kiosk-conn-dot");
|
||||
if (dot) dot.style.background = "#f59e0b";
|
||||
});
|
||||
let active = true;
|
||||
|
||||
connection.onreconnected(() => {
|
||||
const dot = document.getElementById("kiosk-conn-dot");
|
||||
if (dot) dot.style.background = "#16a34a";
|
||||
});
|
||||
async function poll() {
|
||||
if (!active) return;
|
||||
try {
|
||||
const res = await fetch("/Kiosk/PollSession", { cache: "no-store" });
|
||||
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…");
|
||||
window.location.href = `/Kiosk/Intake/${data.sessionToken}/Contact`;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setStatus("#f59e0b", "Connection issue — retrying…");
|
||||
}
|
||||
if (active) setTimeout(poll, 3000);
|
||||
}
|
||||
|
||||
connection.onclose(() => {
|
||||
const dot = document.getElementById("kiosk-conn-dot");
|
||||
if (dot) dot.style.background = "#ef4444";
|
||||
// Keep retrying
|
||||
setTimeout(startConnection, 10000);
|
||||
});
|
||||
setTimeout(poll, 500); // first poll quickly; subsequent every 3s
|
||||
})();
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,11 +1,26 @@
|
||||
// Minimal service worker — required for PWA installability.
|
||||
// No caching: all requests pass through to the network normally.
|
||||
// This exists solely so browsers recognize the site as installable,
|
||||
// which causes iOS/Android to persist camera permissions after "Add to Home Screen."
|
||||
// No caching: all requests pass through to the network.
|
||||
// Exists solely so browsers recognize the site as installable
|
||||
// (iOS/Android persist camera permissions after "Add to Home Screen").
|
||||
//
|
||||
// IMPORTANT: /hubs/ (SignalR) requests are excluded from interception entirely.
|
||||
// Service worker fetch() wraps SSE/WebSocket responses in a buffered Response,
|
||||
// which prevents real-time streaming — SignalR handshakes time out as a result.
|
||||
|
||||
const SKIP_PREFIXES = ['/hubs/', '/Kiosk/PollSession'];
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
self.addEventListener('activate', e => e.waitUntil(self.clients.claim()));
|
||||
|
||||
self.addEventListener('fetch', e => {
|
||||
if (new URL(e.request.url).origin !== self.location.origin) return;
|
||||
const url = new URL(e.request.url);
|
||||
|
||||
// Always skip cross-origin requests
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// Skip SignalR hubs and kiosk polling — let the browser handle these directly
|
||||
if (SKIP_PREFIXES.some(p => url.pathname.startsWith(p))) return;
|
||||
|
||||
// Passthrough: no caching, no modification
|
||||
e.respondWith(fetch(e.request));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user