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>
This commit is contained in:
Scott Idem
2026-03-20 19:15:35 -04:00
parent 6662e82f40
commit fe23899479
6 changed files with 93 additions and 67 deletions

View File

@@ -27,7 +27,7 @@
let {
start_qr_scanner = $bindable(true),
on_qr_scan_result,
qr_fps = 10,
qr_fps = 25,
qr_facing_mode = 'environment'
}: Props = $props();
@@ -90,9 +90,14 @@
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.82);
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

View File

@@ -186,7 +186,7 @@
if (layout === 'badge_3.5x5.5_pvc') {
// 3.5" × 5.5" PVC card — single-sided, compact
return {
grp_name_title: '1.8in',
grp_name_title: '1.6in',
grp_name_title_flex: 'around',
name: '1.4in',
title: '0.55in',
@@ -446,7 +446,7 @@
m-0 p-0
px-1
overflow-clip
flex flex-col gap-1
flex flex-col
items-stretch justify-between
"
>

View File

@@ -6,7 +6,7 @@
import { page } from '$app/state';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_api } from '$lib/stores/ae_stores';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_loc } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { Eye, LoaderCircle, Search, ShieldOff, UserPlus } from '@lucide/svelte';
@@ -86,8 +86,10 @@
adding_id = badge_id;
add_error_id = '';
// Use the actual signed-in licensed user's email (stored in auth_exhibit_kv)
const user_email = $events_loc.leads.auth_exhibit_kv?.[exhibit_id]?.key || 'shared_passcode';
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const user_email = kv?.type === 'licensed' && kv.key ? kv.key
: kv?.type === 'shared' ? 'shared_passcode'
: $ae_loc.access_type || 'anonymous';
try {
const result = await events_func.create_ae_obj__exhibit_tracking({

View File

@@ -16,7 +16,7 @@
import { events_func } from '$lib/ae_events/ae_events_functions';
import Element_qr_scanner_v3 from '$lib/elements/element_qr_scanner_v3.svelte';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { Camera, CircleAlert, CircleCheck, Eye, LoaderCircle, RefreshCw, RotateCcw, ShieldOff, UserPlus, X } from '@lucide/svelte';
import { Camera, CircleAlert, CircleCheck, Eye, LoaderCircle, RefreshCw, RotateCcw, ShieldOff, X } from '@lucide/svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { ae_EventBadge } from '$lib/types/ae_types';
@@ -102,7 +102,7 @@
}
}
async function confirm_add_lead() {
async function confirm_add_lead(dest: 'scan_next' | 'view_lead' = 'scan_next') {
if (!found_badge || !found_badge.event_badge_id) {
console.warn('[leads] Guard failed — event_badge_id missing. found_badge:', found_badge);
return;
@@ -110,8 +110,14 @@
scanning_status = 'adding';
// Use the actual signed-in licensed user's email
const user_email = $events_loc.leads.auth_exhibit_kv?.[exhibit_id]?.key || 'shared_passcode';
// Resolve who is capturing this lead:
// licensed exhibit user → their email (kv.key)
// shared passcode → 'shared_passcode' label (don't store the actual passcode)
// Aether user (no kv) → access_type string ('trusted', 'manager', 'super', etc.)
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const user_email = kv?.type === 'licensed' && kv.key ? kv.key
: kv?.type === 'shared' ? 'shared_passcode'
: $ae_loc.access_type || 'anonymous';
try {
const result = await events_func.create_ae_obj__exhibit_tracking({
@@ -128,11 +134,11 @@
scanning_status = 'success';
if (on_lead_added) on_lead_added(found_badge);
if (scan_qualify === 'qualify' && new_tracking_id) {
// Qualify mode: navigate directly to lead detail to fill in notes/qualifiers
if (dest === 'view_lead' && new_tracking_id) {
// View Lead: navigate directly to lead detail to fill in notes/qualifiers
goto(`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`);
} else {
// Rapid/auto mode: auto-reset after 2 seconds to scan the next person
// Scan Next / auto mode: auto-reset after 2 seconds to scan the next person
setTimeout(reset_scanner, 2000);
}
} else {
@@ -167,7 +173,7 @@
}
}
async function confirm_reenable_lead() {
async function confirm_reenable_lead(dest: 'scan_next' | 'view_lead' = 'scan_next') {
// Re-activate a lead that was previously removed (enable=false).
// existing_tracking_id is already set from the map or the API fallback search.
if (!existing_tracking_id) return;
@@ -185,9 +191,11 @@
new_tracking_id = existing_tracking_id;
scanning_status = 'success';
if (on_lead_added && found_badge) on_lead_added(found_badge);
// Re-enabled lead: success card shows "View Details" link — user navigates manually.
// Auto-reset after 2s so the scanner is ready for the next badge.
setTimeout(reset_scanner, 2000);
if (dest === 'view_lead' && new_tracking_id) {
goto(`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`);
} else {
setTimeout(reset_scanner, 2000);
}
} else {
scanning_status = 'error';
error_msg = 'Failed to restore lead. Please try again.';
@@ -247,23 +255,25 @@
<p class="opacity-70 text-sm">This lead was removed. Re-activate to restore their record including any saved notes and responses.</p>
</div>
<button
type="button"
class="w-full rounded-xl py-5 font-bold text-base flex items-center justify-center gap-2 bg-warning-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer"
onclick={confirm_reenable_lead}
>
<RotateCcw size="1.5em" />
Re-activate Lead
</button>
<a
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${existing_tracking_id}`}
class="btn btn-sm w-full preset-outlined-warning"
class:hidden={!$ae_loc.trusted_access}
>
<Eye size="1em" />
View Existing Record
</a>
<!-- Two-button confirm — same pattern as the main confirm card -->
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-xl py-5 font-bold text-sm flex flex-col items-center justify-center gap-2 bg-warning-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer"
onclick={() => confirm_reenable_lead('scan_next')}
>
<Camera size="1.5em" />
Restore &amp; Scan Next
</button>
<button
type="button"
class="rounded-xl py-5 font-bold text-sm flex flex-col items-center justify-center gap-2 bg-secondary-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer"
onclick={() => confirm_reenable_lead('view_lead')}
>
<Eye size="1.5em" />
Restore &amp; View Lead
</button>
</div>
<button
type="button"
@@ -313,32 +323,36 @@
<p class="opacity-70">{found_badge?.affiliations || ''}</p>
</div>
{#if scan_qualify === 'auto'}
<!-- Auto mode: no confirm buttons — adding happens automatically -->
{#if scan_qualify === 'auto' || scanning_status === 'adding'}
<!-- Auto mode or mid-add: no buttons — adding happens automatically / in progress -->
<div class="flex items-center justify-center gap-3 py-3 opacity-70">
<LoaderCircle class="animate-spin" size="1.5em" />
<span class="font-bold">Auto-adding...</span>
<span class="font-bold">{scan_qualify === 'auto' ? 'Auto-adding...' : 'Adding Lead...'}</span>
</div>
{:else}
<button
type="button"
class="w-full rounded-xl py-5 font-bold text-base flex items-center justify-center gap-2 bg-primary-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer disabled:opacity-50"
disabled={scanning_status === 'adding'}
onclick={confirm_add_lead}
>
{#if scanning_status === 'adding'}
<LoaderCircle class="animate-spin" size="1.5em" />
Adding Lead...
{:else}
<UserPlus size="1.5em" />
Add as Lead
{/if}
</button>
<!-- Two-button confirm: staff chooses what to do after adding this lead -->
<div class="grid grid-cols-2 gap-3">
<button
type="button"
class="rounded-xl py-5 font-bold text-sm flex flex-col items-center justify-center gap-2 bg-primary-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer"
onclick={() => confirm_add_lead('scan_next')}
>
<Camera size="1.5em" />
Add &amp; Scan Next
</button>
<button
type="button"
class="rounded-xl py-5 font-bold text-sm flex flex-col items-center justify-center gap-2 bg-secondary-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer"
onclick={() => confirm_add_lead('view_lead')}
>
<Eye size="1.5em" />
Add &amp; View Lead
</button>
</div>
<button
type="button"
class="w-full rounded-lg py-3 text-sm font-medium flex items-center justify-center gap-2 border border-surface-500/40 hover:bg-surface-200-800 transition-colors cursor-pointer opacity-70 disabled:opacity-30"
disabled={scanning_status === 'adding'}
class="w-full rounded-lg py-3 text-sm font-medium flex items-center justify-center gap-2 border border-surface-500/40 hover:bg-surface-200-800 transition-colors cursor-pointer opacity-70"
onclick={reset_scanner}
>
<X size="1em" />

View File

@@ -16,7 +16,7 @@
import { page } from '$app/state';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { ae_api } from '$lib/stores/ae_stores';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_loc } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions';
import { ae_util } from '$lib/ae_utils/ae_utils';
@@ -182,7 +182,10 @@
if (item.status !== 'ready' || !item.badge?.event_badge_id) return;
item.status = 'adding';
const user_email = $events_loc.leads.auth_exhibit_kv?.[exhibit_id]?.key || 'shared_passcode';
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const user_email = kv?.type === 'licensed' && kv.key ? kv.key
: kv?.type === 'shared' ? 'shared_passcode'
: $ae_loc.access_type || 'anonymous';
try {
const result = await events_func.create_ae_obj__exhibit_tracking({
api_cfg: $ae_api,

View File

@@ -11,7 +11,7 @@
* auto: no confirm — badge is added immediately on scan → auto-reset
* multi: BarcodeDetector batch scan → grid of confirm cards
*/
import { Bot, ChevronDown, ClipboardList, Layers, QrCode, Search, Zap } from '@lucide/svelte';
import { Bot, ChevronDown, Layers, QrCode, Search, Zap } from '@lucide/svelte';
import Comp_lead_qr_scanner from './ae_comp__lead_qr_scanner.svelte';
import Comp_lead_qr_scanner_multi from './ae_comp__lead_qr_scanner_multi.svelte';
import Comp_lead_manual_search from './ae_comp__lead_manual_search.svelte';
@@ -32,8 +32,13 @@
}
// Scan qualify mode (persisted per exhibit)
type ScanQualifyMode = 'rapid' | 'qualify' | 'auto' | 'multi';
let scan_qualify = $derived(($events_loc.leads.tab_scan_qualify?.[exhibit_id] ?? 'rapid') as ScanQualifyMode);
// 'qualify' was merged into 'rapid' — normalize stale localStorage values
type ScanQualifyMode = 'rapid' | 'auto' | 'multi';
let scan_qualify = $derived.by(() => {
const raw = $events_loc.leads.tab_scan_qualify?.[exhibit_id] ?? 'rapid';
// 'qualify' was merged into 'rapid' — normalize stale localStorage values
return (raw === 'qualify' ? 'rapid' : raw) as ScanQualifyMode;
});
function set_scan_qualify(new_mode: ScanQualifyMode) {
if (!$events_loc.leads.tab_scan_qualify) $events_loc.leads.tab_scan_qualify = {};
@@ -55,10 +60,9 @@
desc: string;
icon: any;
}> = [
{ value: 'rapid', label: 'Rapid', desc: 'Confirm · then auto-reset', icon: Zap },
{ value: 'qualify', label: 'Qualify', desc: 'Confirm · open lead detail', icon: ClipboardList },
{ value: 'auto', label: 'Auto', desc: 'Auto-add · no tap needed', icon: Bot },
{ value: 'multi', label: 'Multi', desc: 'Batch scan up to 4 badges', icon: Layers },
{ value: 'rapid', label: 'Confirm', desc: 'Tap Add & Scan or Add & View', icon: Zap },
{ value: 'auto', label: 'Auto', desc: 'Auto-add · no tap needed', icon: Bot },
{ value: 'multi', label: 'Multi', desc: 'Batch scan up to 4 badges', icon: Layers },
];
let active_mode = $derived(qr_modes.find(m => m.value === scan_qualify) ?? qr_modes[0]);
@@ -95,7 +99,6 @@
<!-- Colored icon pill -->
<span class="p-1.5 rounded-lg shrink-0 text-white"
class:bg-primary-500={scan_qualify === 'rapid'}
class:bg-secondary-500={scan_qualify === 'qualify'}
class:bg-tertiary-500={scan_qualify === 'auto'}
class:bg-warning-500={scan_qualify === 'multi'}
>
@@ -117,7 +120,7 @@
<!-- Options grid (2×2) — shown when trigger is tapped -->
{#if show_mode_opts}
<div class="mt-1.5 grid grid-cols-2 gap-2 p-2 bg-surface-50-900 rounded-xl border border-surface-500/20 shadow-lg">
<div class="mt-1.5 grid grid-cols-3 gap-2 p-2 bg-surface-50-900 rounded-xl border border-surface-500/20 shadow-lg">
{#each qr_modes as m}
<button
type="button"
@@ -132,7 +135,6 @@
>
<span class="p-1.5 rounded-lg text-white"
class:bg-primary-500={m.value === 'rapid'}
class:bg-secondary-500={m.value === 'qualify'}
class:bg-tertiary-500={m.value === 'auto'}
class:bg-warning-500={m.value === 'multi'}
>