feat: leads QR scanner — Auto/Multi modes, 4-mode fancy selector, UX polish

Scanner modes (now 4, persisted per exhibit):
- Rapid:   confirm tap → auto-reset (existing, fixed)
- Qualify: confirm tap → navigate to lead detail (existing, fixed)
- Auto:    badge found → auto-add immediately, no confirmation tap needed
- Multi:   BarcodeDetector batch scan → responsive grid of confirm cards

Multi scanner (new ae_comp__lead_qr_scanner_multi.svelte):
- Native BarcodeDetector API (Chrome/Edge/Safari 17+); Firefox fallback message
- 16:9 viewfinder with corner guides + "Align up to 4 badges flat" overlay
- Capture Batch tap → up to 8 QR codes detected in one frame
- Per-card states: loading skeleton, ready (Add/Skip), blocked (opt-out),
  already-captured (View/OK), adding spinner, success (auto-fade), error
- Add All (N) bulk action; cards fade+scale out smoothly on dismiss

Mode selector (ae_tab__add.svelte):
- Replaces Rapid/Qualify toggle with collapsible 4-mode fancy select
- Trigger shows active mode icon (color-coded) + name + description
- 2×2 options grid expands on tap, closes on selection

QR scanner element (element_qr_scanner_v3.svelte):
- object-fit: cover eliminates 4:3 camera letterbox dead zone
- 7-second start timeout with actionable error message
- Starting/error overlays with high-contrast styling
- Try Again button with RefreshCw icon

Style guide updated: icon+text button rule (§8), btn/preset-filled workaround (§12)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-20 16:30:38 -04:00
parent 14c2635df4
commit 334c3a21bc
6 changed files with 727 additions and 106 deletions

View File

@@ -15,6 +15,7 @@
*/
import { onDestroy, untrack } from 'svelte';
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode';
import { RefreshCw } from '@lucide/svelte';
interface Props {
start_qr_scanner?: boolean;
@@ -36,6 +37,7 @@
let scanner: Html5Qrcode | null = null;
let status = $state<'idle' | 'starting' | 'scanning' | 'error'>('idle');
let error_msg = $state('');
let start_timeout: ReturnType<typeof setTimeout> | null = null;
// React to start_qr_scanner prop changes from the parent
$effect(() => {
@@ -51,14 +53,31 @@
});
onDestroy(() => {
clear_start_timeout();
stop_scanning();
});
function clear_start_timeout() {
if (start_timeout !== null) {
clearTimeout(start_timeout);
start_timeout = null;
}
}
async function start_scanning() {
if (status === 'starting' || status === 'scanning') return;
status = 'starting';
error_msg = '';
// If the camera hasn't started within 7 seconds, show a helpful nudge.
// Common causes: permission dialog left open, camera in use by another app, slow device.
start_timeout = setTimeout(() => {
if (status === 'starting') {
status = 'error';
error_msg = 'Camera is taking too long to start. Try refreshing the page, or check that no other app is using your camera.';
}
}, 7000);
try {
scanner = new Html5Qrcode(viewfinder_id, {
formatsToSupport: [Html5QrcodeSupportedFormats.QR_CODE],
@@ -79,8 +98,10 @@
on_scan_error
);
clear_start_timeout();
status = 'scanning';
} catch (e: any) {
clear_start_timeout();
status = 'error';
if (e?.name === 'NotAllowedError') {
error_msg = 'Camera access denied. Please allow camera in your browser settings and try again.';
@@ -92,6 +113,7 @@
}
async function stop_scanning() {
clear_start_timeout();
if (!scanner) return;
const s = scanner;
scanner = null;
@@ -120,22 +142,38 @@
}
</script>
<style>
/* html5-qrcode injects a <video> with inline width/height. Override with cover so the
camera stream fills the container completely on any camera aspect ratio (4:3, 16:9, etc.).
Without this, portrait-mode mobile cameras leave a gray letterbox at the bottom. */
:global(.qr-scanner-v3 video) {
object-fit: cover !important;
width: 100% !important;
height: 100% !important;
}
</style>
<!-- Viewfinder fills whatever container the parent provides -->
<div class="qr-scanner-v3 w-full h-full flex flex-col items-center justify-center">
<div id={viewfinder_id} class="w-full h-full"></div>
{#if status === 'starting'}
<div class="absolute inset-0 flex items-center justify-center bg-surface-900/40 rounded-xl">
<p class="text-sm font-semibold opacity-70 animate-pulse">Starting camera...</p>
<div class="absolute inset-0 flex items-center justify-center bg-black/30 rounded-xl">
<span class="bg-black/60 text-white text-sm font-semibold px-4 py-2 rounded-full animate-pulse shadow-lg">
Starting camera...
</span>
</div>
{:else if status === 'error'}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-surface-900/80 rounded-xl p-6 text-center">
<p class="text-sm text-error-400 font-semibold leading-snug">{error_msg}</p>
<div class="absolute inset-0 flex flex-col items-center justify-center gap-5 bg-black/75 rounded-xl p-6 text-center">
<p class="text-white text-sm font-semibold leading-snug drop-shadow">{error_msg}</p>
<!-- bg-white + dark text: always readable on the dark camera overlay regardless of theme.
preset-filled-primary is theme-adaptive and has poor contrast on black/75 backgrounds. -->
<button
type="button"
class="btn btn-sm preset-filled-primary"
class="bg-white text-surface-950 hover:bg-surface-100 font-bold text-base px-8 py-3 rounded-xl shadow-lg transition-colors cursor-pointer flex items-center gap-2"
onclick={start_scanning}
>
<RefreshCw size="1.2em" />
Try Again
</button>
</div>