Compare commits

..

3 Commits

Author SHA1 Message Date
spouliot f1d7054b3e Fix AI quote reliability on mobile: compress photos + add fetch timeout
- Compress photos client-side before uploading (1200px max, JPEG 85%):
  full-res phone photos (5-15 MB) → ~150-250 KB, dramatically reducing
  upload time on slow mobile connections and Anthropic processing time
- Add 120s AbortController to both AiAnalyzeItem fetch calls so a stalled
  mobile connection produces a clear 'timed out' error instead of spinning forever
- After 30s show 'Still analyzing… this can take a minute on mobile' to
  reassure users the request is in progress
- Reset loading text on retry so the slow-connection hint doesn't persist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:22:32 -04:00
spouliot 46b950baf2 Kiosk intake: 45-second inactivity reset to Welcome screen
_KioskLayout inactivity timer now reads ViewBag.InactivityTimeoutMs
(defaults to 5 min). PopulateKioskViewBagFromSession sets it to 45 s
on every intake step so an abandoned form auto-returns to the waiting
screen. Welcome screen and Confirmation page are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:16:34 -04:00
spouliot 4e9c9d321a Fix kiosk signature pad: host locally, fix canvas resize timing
- Download signature_pad 4.1.7 to wwwroot/lib/signature-pad/ to eliminate
  CDN SRI hash failures and network dependencies on the tablet
- Wrap resizeCanvas in requestAnimationFrame so offsetWidth is non-zero
  when measured (browser layout pass must complete first)
- Add guard for SignaturePad not defined (shows user-visible error instead
  of silent JS crash)
- Add scrollIntoView on signature validation error for better tablet UX

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:14:49 -04:00
6 changed files with 141 additions and 48 deletions
@@ -717,5 +717,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;
}
}
@@ -90,9 +90,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>
}
}
@@ -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);
@@ -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) {
+49 -36
View File
@@ -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");
}
});
}
})();
File diff suppressed because one or more lines are too long