Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1d7054b3e | |||
| 46b950baf2 | |||
| 4e9c9d321a |
@@ -717,5 +717,9 @@ public class KioskController : Controller
|
|||||||
|
|
||||||
ViewBag.SessionToken = session.SessionToken;
|
ViewBag.SessionToken = session.SessionToken;
|
||||||
ViewBag.SessionType = session.SessionType;
|
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)
|
@if (isInPerson)
|
||||||
{
|
{
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script src="https://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js"
|
<script src="~/lib/signature-pad/signature_pad.umd.min.js"></script>
|
||||||
integrity="sha384-bQMMRVcRi5vEIBLKnB4FY7tBOA9k/Qvd/9zSWMNO4h0zfB2qLj4DV2R/JyPAbF3"
|
|
||||||
crossorigin="anonymous"></script>
|
|
||||||
<script src="~/js/kiosk-terms.js"></script>
|
<script src="~/js/kiosk-terms.js"></script>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,16 +59,19 @@
|
|||||||
|
|
||||||
</div>
|
</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);
|
bool showInactivityTimer = (bool)(ViewBag.ShowInactivityTimer ?? true);
|
||||||
string welcomeUrl = ViewBag.WelcomeUrl as string ?? "/Kiosk/Welcome";
|
string welcomeUrl = ViewBag.WelcomeUrl as string ?? "/Kiosk/Welcome";
|
||||||
|
int inactivityMs = ViewBag.InactivityTimeoutMs as int? ?? (5 * 60 * 1000);
|
||||||
}
|
}
|
||||||
@if (showInactivityTimer)
|
@if (showInactivityTimer)
|
||||||
{
|
{
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
var TIMEOUT_MS = 5 * 60 * 1000;
|
var TIMEOUT_MS = @inactivityMs;
|
||||||
var timer;
|
var timer;
|
||||||
function reset() {
|
function reset() {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
|
|||||||
@@ -1166,16 +1166,50 @@ function aiHandleDrop(event) {
|
|||||||
Array.from(event.dataTransfer.files).forEach(aiUploadFile);
|
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) {
|
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 previewUrl = await new Promise(resolve => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = e => resolve(e.target.result);
|
reader.onload = e => resolve(e.target.result);
|
||||||
reader.onerror = () => resolve('');
|
reader.onerror = () => resolve('');
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(compressed);
|
||||||
});
|
});
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', compressed);
|
||||||
formData.append('__RequestVerificationToken',
|
formData.append('__RequestVerificationToken',
|
||||||
document.querySelector('input[name="__RequestVerificationToken"]')?.value || '');
|
document.querySelector('input[name="__RequestVerificationToken"]')?.value || '');
|
||||||
|
|
||||||
@@ -1278,15 +1312,27 @@ async function aiAnalyze() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const analyzeUrl = pageMeta.aiAnalyzeUrl || '/Quotes/AiAnalyzeItem';
|
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 {
|
try {
|
||||||
const resp = await fetch(analyzeUrl, {
|
const resp = await fetch(analyzeUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
signal: controller.signal,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
|
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
clearTimeout(hardTimeout);
|
||||||
|
clearTimeout(slowWarning);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
if (resp.status === 401 || resp.status === 302 || resp.redirected) {
|
if (resp.status === 401 || resp.status === 302 || resp.redirected) {
|
||||||
throw new Error('Your session has expired. Please refresh the page and sign in again.');
|
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();
|
const result = await resp.json();
|
||||||
aiHandleResult(result);
|
aiHandleResult(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearTimeout(hardTimeout);
|
||||||
|
clearTimeout(slowWarning);
|
||||||
console.error('AI analyze error:', err);
|
console.error('AI analyze error:', err);
|
||||||
aiSetLoading(false);
|
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 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 {
|
try {
|
||||||
const resp = await fetch(analyzeUrl, {
|
const resp = await fetch(analyzeUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
signal: controller2.signal,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
|
'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value || ''
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
clearTimeout(hardTimeout2);
|
||||||
|
clearTimeout(slowWarning2);
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
if (resp.status === 401 || resp.status === 302 || resp.redirected) {
|
if (resp.status === 401 || resp.status === 302 || resp.redirected) {
|
||||||
throw new Error('Your session has expired. Please refresh the page and sign in again.');
|
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();
|
const result = await resp.json();
|
||||||
aiHandleResult(result);
|
aiHandleResult(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
clearTimeout(hardTimeout2);
|
||||||
|
clearTimeout(slowWarning2);
|
||||||
console.error('AI follow-up error:', err);
|
console.error('AI follow-up error:', err);
|
||||||
aiSetLoading(false);
|
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) {
|
function aiSetLoading(isLoading) {
|
||||||
const btn = document.getElementById('ai_analyzeBtn');
|
const btn = document.getElementById('ai_analyzeBtn');
|
||||||
const spinner = document.getElementById('ai_loadingSpinner');
|
const spinner = document.getElementById('ai_loadingSpinner');
|
||||||
const text = document.getElementById('ai_loadingText');
|
const text = document.getElementById('ai_loadingText');
|
||||||
if (btn) btn.disabled = isLoading;
|
if (btn) btn.disabled = isLoading;
|
||||||
spinner?.classList.toggle('d-none', !isLoading);
|
spinner?.classList.toggle('d-none', !isLoading);
|
||||||
text?.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) {
|
function aiShowError(message) {
|
||||||
|
|||||||
@@ -3,46 +3,59 @@
|
|||||||
(function () {
|
(function () {
|
||||||
// ── Signature pad (InPerson sessions only) ─────────────────────────────────
|
// ── Signature pad (InPerson sessions only) ─────────────────────────────────
|
||||||
const canvas = document.getElementById("signatureCanvas");
|
const canvas = document.getElementById("signatureCanvas");
|
||||||
if (canvas) {
|
if (!canvas) return;
|
||||||
const pad = new SignaturePad(canvas, { penColor: "#1e293b" });
|
|
||||||
|
|
||||||
// Scale canvas to device pixel ratio for crisp rendering on high-DPI tablets
|
if (typeof SignaturePad === "undefined") {
|
||||||
function resizeCanvas() {
|
console.error("signature_pad failed to load — signature capture unavailable");
|
||||||
const ratio = Math.max(window.devicePixelRatio || 1, 1);
|
canvas.parentElement.insertAdjacentHTML("beforeend",
|
||||||
canvas.width = canvas.offsetWidth * ratio;
|
'<p class="text-danger small mt-1">Signature pad failed to load. Please refresh the page.</p>');
|
||||||
canvas.height = canvas.offsetHeight * ratio;
|
return;
|
||||||
canvas.getContext("2d").scale(ratio, ratio);
|
}
|
||||||
pad.clear();
|
|
||||||
}
|
|
||||||
resizeCanvas();
|
|
||||||
window.addEventListener("resize", resizeCanvas);
|
|
||||||
|
|
||||||
// Show visual feedback when the canvas has been signed
|
const pad = new SignaturePad(canvas, { penColor: "#1e293b" });
|
||||||
pad.addEventListener("endStroke", function () {
|
|
||||||
canvas.classList.add("signed");
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById("clearSignatureBtn")?.addEventListener("click", function () {
|
// Scale canvas to device pixel ratio — must run after layout so offsetWidth is non-zero.
|
||||||
pad.clear();
|
// requestAnimationFrame ensures the browser has finished its first layout pass.
|
||||||
canvas.classList.remove("signed");
|
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
|
requestAnimationFrame(resizeCanvas);
|
||||||
const form = document.getElementById("termsForm");
|
window.addEventListener("resize", resizeCanvas);
|
||||||
if (form) {
|
|
||||||
form.addEventListener("submit", function (e) {
|
// Visual feedback when the canvas has been signed
|
||||||
const hiddenInput = document.getElementById("SignatureDataBase64");
|
pad.addEventListener("endStroke", function () {
|
||||||
if (hiddenInput) {
|
canvas.classList.add("signed");
|
||||||
if (pad.isEmpty()) {
|
});
|
||||||
e.preventDefault();
|
|
||||||
const msg = document.getElementById("signatureError");
|
document.getElementById("clearSignatureBtn")?.addEventListener("click", function () {
|
||||||
if (msg) msg.classList.remove("d-none");
|
pad.clear();
|
||||||
canvas.classList.add("is-invalid");
|
canvas.classList.remove("signed");
|
||||||
return;
|
});
|
||||||
}
|
|
||||||
hiddenInput.value = pad.toDataURL("image/png");
|
// 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
Reference in New Issue
Block a user