Files
OSIT-AE-App-Svelte/src/lib/elements/element_qr_scanner_v3.svelte
Scott Idem fe23899479 feat: leads QR UX — merged confirm modes, faster scanning, correct capture identity
- Merge Rapid + Qualify scan modes into single Confirm mode with two-button card:
  "Add & Scan Next" (resets) and "Add & View Lead" (navigates to detail). Same
  two-button pattern on the reenable card: "Restore & Scan Next" / "Restore & View Lead".
  Stale 'qualify' localStorage values normalized to 'rapid' via $derived.by().
- QR scanner speed: fps 10→25, qrbox 82%→88%, useBarCodeDetectorIfSupported (native
  BarcodeDetector API on Chrome/Edge — significantly faster than ZXing JS fallback)
- Fix capture identity stored in external_person_id / group:
  licensed exhibit user → their email; shared passcode → 'shared_passcode' label
  (not the raw passcode); Aether user bypassing exhibit sign-in → access_type string
  ('trusted', 'manager', 'super', etc.). Consistent across all three lead capture
  components (single scanner, multi scanner, manual search).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 19:15:35 -04:00

187 lines
7.2 KiB
Svelte

<script lang="ts">
/**
* src/lib/elements/element_qr_scanner_v3.svelte
* QR Scanner v3 — Svelte 5 runes, auto-starts, no manual permission step.
*
* html5-qrcode's .start() handles camera permission internally.
* A unique viewfinder ID is generated per instance so multiple scanners
* can coexist on the same page without collision.
*
* Props:
* start_qr_scanner (bindable) — true = scan, false = stop
* on_qr_scan_result — callback fired with { detail: { result, entry_method } }
* qr_fps — scan frames per second (default 10)
* qr_facing_mode — 'environment' (rear) or 'user' (front)
*/
import { onDestroy, untrack } from 'svelte';
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode';
import { RefreshCw } from '@lucide/svelte';
interface Props {
start_qr_scanner?: boolean;
on_qr_scan_result?: (event: { detail: { result: string; entry_method: string } }) => void;
qr_fps?: number;
qr_facing_mode?: string;
}
let {
start_qr_scanner = $bindable(true),
on_qr_scan_result,
qr_fps = 25,
qr_facing_mode = 'environment'
}: Props = $props();
// Unique DOM ID per instance — prevents conflicts when multiple scanners mount
const viewfinder_id = `qr_vf_${Math.random().toString(36).substring(2, 9)}`;
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(() => {
const should_scan = start_qr_scanner;
untrack(() => {
if (should_scan && (status === 'idle' || status === 'error')) {
start_scanning();
} else if (!should_scan && status === 'scanning') {
stop_scanning();
}
});
});
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],
verbose: false
});
await scanner.start(
{ facingMode: qr_facing_mode },
{
fps: qr_fps,
// Use a percentage of the viewfinder so it scales on any screen size
qrbox: (w: number, h: number) => {
const side = Math.floor(Math.min(w, h) * 0.88);
return { width: side, height: side };
},
// Use native BarcodeDetector API on Chrome/Edge — significantly faster
// than the JS ZXing fallback used on Firefox/older Safari.
// Cast: experimentalFeatures exists at runtime but is missing from the
// html5-qrcode TypeScript type definitions for this version.
...({ experimentalFeatures: { useBarCodeDetectorIfSupported: true } } as { experimentalFeatures: { useBarCodeDetectorIfSupported: boolean } })
},
on_scan_success,
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.';
} else {
error_msg = 'Could not start camera. Please try again.';
}
console.warn('[QR v3] Scanner start failed:', e);
}
}
async function stop_scanning() {
clear_start_timeout();
if (!scanner) return;
const s = scanner;
scanner = null;
try {
await s.stop();
await s.clear();
} catch {
// Ignore cleanup errors — component may be unmounting
}
status = 'idle';
}
function on_scan_success(decoded_text: string) {
// Stop scanning before notifying parent so the camera shuts down cleanly
stop_scanning().then(() => {
start_qr_scanner = false;
});
if (on_qr_scan_result) {
on_qr_scan_result({ detail: { result: decoded_text, entry_method: 'QR' } });
}
}
function on_scan_error(_msg: string) {
// Called on every frame that doesn't contain a QR code — expected, not an error
}
</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-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-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="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>
{/if}
</div>