feat(leads): V3 API migration, QR Scanner v3, and Exhibitor Leads UI overhaul
- Migrate event_exhibit and event_exhibit_tracking CRUD to V3 API (parent_type/child_type params). - Implement Element_qr_scanner_v3.svelte: A Svelte 5 / Runes component using html5-qrcode with auto-start and unique viewfinder IDs. - Integrate QR Scanner v3 into ae_comp__badge_search.svelte and lead capture. - Refactor Exhibitor Leads UI: - Add 'Rapid Scan' vs 'Qualify Mode' toggles for efficient lead capture. - Upgrade ae_comp__lead_detail_form.svelte to support new question/response schema with backward compatibility. - Implement 'Sign Out of Booth' functionality in exhibit management. - Optimize lead detail layout for mobile readability and high information density. - Fix component prop sync for event_id and exhibit_id. - UI/UX refinements: standardizing icons (SquarePen), cleaning up unused imports, and improving responsive states.
This commit is contained in:
@@ -309,9 +309,9 @@ export async function create_ae_obj__exhibit({
|
||||
}): Promise<ae_EventExhibit | null> {
|
||||
const result = await api.create_nested_obj_v3({
|
||||
api_cfg,
|
||||
for_obj_type: 'event',
|
||||
for_obj_id: event_id,
|
||||
obj_type: 'event_exhibit',
|
||||
parent_type: 'event',
|
||||
parent_id: event_id,
|
||||
child_type: 'event_exhibit',
|
||||
fields: { ...data_kv },
|
||||
log_lvl
|
||||
});
|
||||
@@ -353,10 +353,10 @@ export async function update_ae_obj__exhibit({
|
||||
}): Promise<ae_EventExhibit | null> {
|
||||
const result = await api.update_nested_obj_v3({
|
||||
api_cfg,
|
||||
for_obj_type: 'event',
|
||||
for_obj_id: event_id,
|
||||
obj_type: 'event_exhibit',
|
||||
obj_id: exhibit_id,
|
||||
parent_type: 'event',
|
||||
parent_id: event_id,
|
||||
child_type: 'event_exhibit',
|
||||
child_id: exhibit_id,
|
||||
fields: data_kv,
|
||||
log_lvl
|
||||
});
|
||||
|
||||
@@ -326,9 +326,9 @@ export async function create_ae_obj__exhibit_tracking({
|
||||
}): Promise<ae_EventExhibitTracking | null> {
|
||||
const result = await api.create_nested_obj_v3({
|
||||
api_cfg,
|
||||
for_obj_type: 'event_exhibit',
|
||||
for_obj_id: exhibit_id,
|
||||
obj_type: 'event_exhibit_tracking',
|
||||
parent_type: 'event_exhibit',
|
||||
parent_id: exhibit_id,
|
||||
child_type: 'event_exhibit_tracking',
|
||||
fields: {
|
||||
event_badge_id: event_badge_id,
|
||||
external_person_id,
|
||||
@@ -374,10 +374,10 @@ export async function update_ae_obj__exhibit_tracking({
|
||||
}): Promise<ae_EventExhibitTracking | null> {
|
||||
const result = await api.update_nested_obj_v3({
|
||||
api_cfg,
|
||||
for_obj_type: 'event_exhibit',
|
||||
for_obj_id: exhibit_id,
|
||||
obj_type: 'event_exhibit_tracking',
|
||||
obj_id: exhibit_tracking_id,
|
||||
parent_type: 'event_exhibit',
|
||||
parent_id: exhibit_id,
|
||||
child_type: 'event_exhibit_tracking',
|
||||
child_id: exhibit_tracking_id,
|
||||
fields: data,
|
||||
log_lvl
|
||||
});
|
||||
|
||||
143
src/lib/element_qr_scanner_v3.svelte
Normal file
143
src/lib/element_qr_scanner_v3.svelte
Normal file
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/lib/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';
|
||||
|
||||
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 = 10,
|
||||
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('');
|
||||
|
||||
// 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(() => {
|
||||
stop_scanning();
|
||||
});
|
||||
|
||||
async function start_scanning() {
|
||||
if (status === 'starting' || status === 'scanning') return;
|
||||
status = 'starting';
|
||||
error_msg = '';
|
||||
|
||||
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.82);
|
||||
return { width: side, height: side };
|
||||
}
|
||||
},
|
||||
on_scan_success,
|
||||
on_scan_error
|
||||
);
|
||||
|
||||
status = 'scanning';
|
||||
} catch (e: any) {
|
||||
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() {
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
{: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>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-filled-primary"
|
||||
onclick={start_scanning}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user