Compare commits
3 Commits
0c8723ef84
...
f1d7054b3e
| Author | SHA1 | Date | |
|---|---|---|---|
| f1d7054b3e | |||
| 46b950baf2 | |||
| 4e9c9d321a |
@@ -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,11 +1347,17 @@ async function aiAnalyze() {
|
||||
const result = await resp.json();
|
||||
aiHandleResult(result);
|
||||
} catch (err) {
|
||||
clearTimeout(hardTimeout);
|
||||
clearTimeout(slowWarning);
|
||||
console.error('AI analyze error:', err);
|
||||
aiSetLoading(false);
|
||||
if (err.name === 'AbortError') {
|
||||
aiShowError('The request timed out — your connection may be slow. Please try again.');
|
||||
} else {
|
||||
aiShowError(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function aiSendFollowup() {
|
||||
const answer = document.getElementById('ai_followupAnswer')?.value?.trim();
|
||||
@@ -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,11 +1423,17 @@ 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);
|
||||
if (err.name === 'AbortError') {
|
||||
aiShowError('The request timed out — your connection may be slow. Please try again.');
|
||||
} else {
|
||||
aiShowError(err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function aiHandleResult(result) {
|
||||
aiSetLoading(false);
|
||||
@@ -1451,6 +1518,8 @@ function aiSetLoading(isLoading) {
|
||||
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,21 +3,34 @@
|
||||
(function () {
|
||||
// ── Signature pad (InPerson sessions only) ─────────────────────────────────
|
||||
const canvas = document.getElementById("signatureCanvas");
|
||||
if (canvas) {
|
||||
if (!canvas) return;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const pad = new SignaturePad(canvas, { penColor: "#1e293b" });
|
||||
|
||||
// Scale canvas to device pixel ratio for crisp rendering on high-DPI tablets
|
||||
// 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);
|
||||
canvas.width = canvas.offsetWidth * ratio;
|
||||
canvas.height = canvas.offsetHeight * ratio;
|
||||
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();
|
||||
}
|
||||
resizeCanvas();
|
||||
|
||||
requestAnimationFrame(resizeCanvas);
|
||||
window.addEventListener("resize", resizeCanvas);
|
||||
|
||||
// Show visual feedback when the canvas has been signed
|
||||
// Visual feedback when the canvas has been signed
|
||||
pad.addEventListener("endStroke", function () {
|
||||
canvas.classList.add("signed");
|
||||
});
|
||||
@@ -38,11 +51,11 @@
|
||||
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
Reference in New Issue
Block a user