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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
"
|
||||
>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user