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 { let {
start_qr_scanner = $bindable(true), start_qr_scanner = $bindable(true),
on_qr_scan_result, on_qr_scan_result,
qr_fps = 10, qr_fps = 25,
qr_facing_mode = 'environment' qr_facing_mode = 'environment'
}: Props = $props(); }: Props = $props();
@@ -90,9 +90,14 @@
fps: qr_fps, fps: qr_fps,
// Use a percentage of the viewfinder so it scales on any screen size // Use a percentage of the viewfinder so it scales on any screen size
qrbox: (w: number, h: number) => { 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 }; 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_success,
on_scan_error on_scan_error

View File

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

View File

@@ -6,7 +6,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events'; 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_loc } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions'; import { events_func } from '$lib/ae_events/ae_events_functions';
import { Eye, LoaderCircle, Search, ShieldOff, UserPlus } from '@lucide/svelte'; import { Eye, LoaderCircle, Search, ShieldOff, UserPlus } from '@lucide/svelte';
@@ -86,8 +86,10 @@
adding_id = badge_id; adding_id = badge_id;
add_error_id = ''; add_error_id = '';
// Use the actual signed-in licensed user's email (stored in auth_exhibit_kv) const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const user_email = $events_loc.leads.auth_exhibit_kv?.[exhibit_id]?.key || 'shared_passcode'; const user_email = kv?.type === 'licensed' && kv.key ? kv.key
: kv?.type === 'shared' ? 'shared_passcode'
: $ae_loc.access_type || 'anonymous';
try { try {
const result = await events_func.create_ae_obj__exhibit_tracking({ 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 { events_func } from '$lib/ae_events/ae_events_functions';
import Element_qr_scanner_v3 from '$lib/elements/element_qr_scanner_v3.svelte'; import Element_qr_scanner_v3 from '$lib/elements/element_qr_scanner_v3.svelte';
import { ae_util } from '$lib/ae_utils/ae_utils'; 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 { SvelteMap } from 'svelte/reactivity';
import type { ae_EventBadge } from '$lib/types/ae_types'; 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) { if (!found_badge || !found_badge.event_badge_id) {
console.warn('[leads] Guard failed — event_badge_id missing. found_badge:', found_badge); console.warn('[leads] Guard failed — event_badge_id missing. found_badge:', found_badge);
return; return;
@@ -110,8 +110,14 @@
scanning_status = 'adding'; scanning_status = 'adding';
// Use the actual signed-in licensed user's email // Resolve who is capturing this lead:
const user_email = $events_loc.leads.auth_exhibit_kv?.[exhibit_id]?.key || 'shared_passcode'; // 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 { try {
const result = await events_func.create_ae_obj__exhibit_tracking({ const result = await events_func.create_ae_obj__exhibit_tracking({
@@ -128,11 +134,11 @@
scanning_status = 'success'; scanning_status = 'success';
if (on_lead_added) on_lead_added(found_badge); if (on_lead_added) on_lead_added(found_badge);
if (scan_qualify === 'qualify' && new_tracking_id) { if (dest === 'view_lead' && new_tracking_id) {
// Qualify mode: navigate directly to lead detail to fill in notes/qualifiers // 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}`); goto(`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`);
} else { } 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); setTimeout(reset_scanner, 2000);
} }
} else { } 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). // Re-activate a lead that was previously removed (enable=false).
// existing_tracking_id is already set from the map or the API fallback search. // existing_tracking_id is already set from the map or the API fallback search.
if (!existing_tracking_id) return; if (!existing_tracking_id) return;
@@ -185,9 +191,11 @@
new_tracking_id = existing_tracking_id; new_tracking_id = existing_tracking_id;
scanning_status = 'success'; scanning_status = 'success';
if (on_lead_added && found_badge) on_lead_added(found_badge); if (on_lead_added && found_badge) on_lead_added(found_badge);
// Re-enabled lead: success card shows "View Details" link — user navigates manually. if (dest === 'view_lead' && new_tracking_id) {
// Auto-reset after 2s so the scanner is ready for the next badge. goto(`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`);
setTimeout(reset_scanner, 2000); } else {
setTimeout(reset_scanner, 2000);
}
} else { } else {
scanning_status = 'error'; scanning_status = 'error';
error_msg = 'Failed to restore lead. Please try again.'; 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> <p class="opacity-70 text-sm">This lead was removed. Re-activate to restore their record including any saved notes and responses.</p>
</div> </div>
<button <!-- Two-button confirm — same pattern as the main confirm card -->
type="button" <div class="grid grid-cols-2 gap-3">
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" <button
onclick={confirm_reenable_lead} 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"
<RotateCcw size="1.5em" /> onclick={() => confirm_reenable_lead('scan_next')}
Re-activate Lead >
</button> <Camera size="1.5em" />
Restore &amp; Scan Next
<a </button>
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${existing_tracking_id}`} <button
class="btn btn-sm w-full preset-outlined-warning" type="button"
class:hidden={!$ae_loc.trusted_access} 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="1em" /> >
View Existing Record <Eye size="1.5em" />
</a> Restore &amp; View Lead
</button>
</div>
<button <button
type="button" type="button"
@@ -313,32 +323,36 @@
<p class="opacity-70">{found_badge?.affiliations || ''}</p> <p class="opacity-70">{found_badge?.affiliations || ''}</p>
</div> </div>
{#if scan_qualify === 'auto'} {#if scan_qualify === 'auto' || scanning_status === 'adding'}
<!-- Auto mode: no confirm buttons — adding happens automatically --> <!-- 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"> <div class="flex items-center justify-center gap-3 py-3 opacity-70">
<LoaderCircle class="animate-spin" size="1.5em" /> <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> </div>
{:else} {:else}
<button <!-- Two-button confirm: staff chooses what to do after adding this lead -->
type="button" <div class="grid grid-cols-2 gap-3">
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" <button
disabled={scanning_status === 'adding'} type="button"
onclick={confirm_add_lead} 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')}
{#if scanning_status === 'adding'} >
<LoaderCircle class="animate-spin" size="1.5em" /> <Camera size="1.5em" />
Adding Lead... Add &amp; Scan Next
{:else} </button>
<UserPlus size="1.5em" /> <button
Add as Lead type="button"
{/if} 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"
</button> onclick={() => confirm_add_lead('view_lead')}
>
<Eye size="1.5em" />
Add &amp; View Lead
</button>
</div>
<button <button
type="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" 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={scanning_status === 'adding'}
onclick={reset_scanner} onclick={reset_scanner}
> >
<X size="1em" /> <X size="1em" />

View File

@@ -16,7 +16,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events'; 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_loc } from '$lib/stores/ae_events_stores';
import { events_func } from '$lib/ae_events/ae_events_functions'; import { events_func } from '$lib/ae_events/ae_events_functions';
import { ae_util } from '$lib/ae_utils/ae_utils'; import { ae_util } from '$lib/ae_utils/ae_utils';
@@ -182,7 +182,10 @@
if (item.status !== 'ready' || !item.badge?.event_badge_id) return; if (item.status !== 'ready' || !item.badge?.event_badge_id) return;
item.status = 'adding'; 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 { try {
const result = await events_func.create_ae_obj__exhibit_tracking({ const result = await events_func.create_ae_obj__exhibit_tracking({
api_cfg: $ae_api, api_cfg: $ae_api,

View File

@@ -11,7 +11,7 @@
* auto: no confirm — badge is added immediately on scan → auto-reset * auto: no confirm — badge is added immediately on scan → auto-reset
* multi: BarcodeDetector batch scan → grid of confirm cards * 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 from './ae_comp__lead_qr_scanner.svelte';
import Comp_lead_qr_scanner_multi from './ae_comp__lead_qr_scanner_multi.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'; import Comp_lead_manual_search from './ae_comp__lead_manual_search.svelte';
@@ -32,8 +32,13 @@
} }
// Scan qualify mode (persisted per exhibit) // Scan qualify mode (persisted per exhibit)
type ScanQualifyMode = 'rapid' | 'qualify' | 'auto' | 'multi'; // 'qualify' was merged into 'rapid' — normalize stale localStorage values
let scan_qualify = $derived(($events_loc.leads.tab_scan_qualify?.[exhibit_id] ?? 'rapid') as ScanQualifyMode); 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) { function set_scan_qualify(new_mode: ScanQualifyMode) {
if (!$events_loc.leads.tab_scan_qualify) $events_loc.leads.tab_scan_qualify = {}; if (!$events_loc.leads.tab_scan_qualify) $events_loc.leads.tab_scan_qualify = {};
@@ -55,10 +60,9 @@
desc: string; desc: string;
icon: any; icon: any;
}> = [ }> = [
{ value: 'rapid', label: 'Rapid', desc: 'Confirm · then auto-reset', icon: Zap }, { value: 'rapid', label: 'Confirm', desc: 'Tap Add & Scan or Add & View', 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: '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: '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]); let active_mode = $derived(qr_modes.find(m => m.value === scan_qualify) ?? qr_modes[0]);
@@ -95,7 +99,6 @@
<!-- Colored icon pill --> <!-- Colored icon pill -->
<span class="p-1.5 rounded-lg shrink-0 text-white" <span class="p-1.5 rounded-lg shrink-0 text-white"
class:bg-primary-500={scan_qualify === 'rapid'} class:bg-primary-500={scan_qualify === 'rapid'}
class:bg-secondary-500={scan_qualify === 'qualify'}
class:bg-tertiary-500={scan_qualify === 'auto'} class:bg-tertiary-500={scan_qualify === 'auto'}
class:bg-warning-500={scan_qualify === 'multi'} class:bg-warning-500={scan_qualify === 'multi'}
> >
@@ -117,7 +120,7 @@
<!-- Options grid (2×2) — shown when trigger is tapped --> <!-- Options grid (2×2) — shown when trigger is tapped -->
{#if show_mode_opts} {#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} {#each qr_modes as m}
<button <button
type="button" type="button"
@@ -132,7 +135,6 @@
> >
<span class="p-1.5 rounded-lg text-white" <span class="p-1.5 rounded-lg text-white"
class:bg-primary-500={m.value === 'rapid'} class:bg-primary-500={m.value === 'rapid'}
class:bg-secondary-500={m.value === 'qualify'}
class:bg-tertiary-500={m.value === 'auto'} class:bg-tertiary-500={m.value === 'auto'}
class:bg-warning-500={m.value === 'multi'} class:bg-warning-500={m.value === 'multi'}
> >