diff --git a/documentation/GUIDE__AE_UI_Style_Guidelines.md b/documentation/GUIDE__AE_UI_Style_Guidelines.md index 3027a2dd..1cea4f3e 100644 --- a/documentation/GUIDE__AE_UI_Style_Guidelines.md +++ b/documentation/GUIDE__AE_UI_Style_Guidelines.md @@ -1,5 +1,5 @@ # Aether UI โ Design System Style Guidelines -> **Version:** 1.1 (2026-03-17) +> **Version:** 1.2 (2026-03-20) > **Author:** One Sky IT / Scott Idem > **Scope:** All Aether SvelteKit frontend components > **Related:** `AE__UI_Component_Patterns.md`, `ae-firefly.css`, `documentation/AE__Components.md` @@ -34,6 +34,7 @@ To maintain codebase health and performance, all new development must adhere to - **Mandatory**: Use `preset-*` classes for interactive elements (e.g., `preset-tonal-primary`). - **Forbidden**: Legacy Skeleton v3 `variant-*` classes. - **Customization**: Use Tailwind 4 `@theme` blocks for project-wide overrides. +- **URLs**: Skeleton for Svelte for LLMs docs: https://www.skeleton.dev/llms-svelte.txt ### ๐ฃ Lucide Icons - **Mandatory**: Use `@lucide/svelte` components (e.g., ``). @@ -192,6 +193,7 @@ Always wrap in `{#if $lq__obj}{...}{:else}...skeleton...{/if}` โ **never** sho | Form inputs | Visible `` linked via `for` / `id`, or explicit `aria-label` | | Color-only information | Always pair color coding with icon or text โ never color alone | | Minimum touch target | 44ร44px effective hit area for all tap targets | +| Button label + icon | All buttons should include **both a Lucide icon and text label**. Icon-only is acceptable for space-constrained toolbar/header actions (with `title` attribute); text-only is acceptable when layout is extremely tight. The icon+text combination aids non-English-native users who may not read the label fluently. | --- @@ -241,3 +243,34 @@ $events_sess.pres_mgmt.session_qr_url[$lq__obj.id] = result; // โ URL string - **`text-sm leading-relaxed`**: Standard for body-level descriptive text in cards. - **`tracking-wide uppercase`**: Use for section label/eyebrow text with `opacity-40`. - **`whitespace-pre-wrap`**: Required for any `` or `` displaying user-entered multi-line text (preserves breaks without horizontal overflow). + +--- + +## 12. Known Issues & Workarounds + +### `btn` + `preset-filled-*` resolves to transparent inside `card` components + +**Symptom:** A button using `btn preset-filled-primary` (or any `preset-filled-*`) inside a `card` div renders with `background-color: transparent`, making it invisible against the card surface. + +**Root cause:** The Skeleton v4 `btn` class sets a transparent background via a CSS variable chain. When nested inside a `card` element, the `preset-filled-*` class fails to win the specificity battle and the button appears invisible. This affects both light and dark mode. + +**Workaround:** Skip `btn` and `preset-filled-*` entirely for buttons inside `card` elements. Use direct Tailwind token classes instead: + +```svelte + + + ... + + + + + ... + + + +... +``` + +**Scope:** `btn` + `preset-*` classes work correctly on standalone buttons (e.g. page headers, nav bars). The issue is specific to the `card` component context. If we migrate away from Skeleton `card`/`btn`, this issue goes away. diff --git a/src/lib/elements/element_qr_scanner_v3.svelte b/src/lib/elements/element_qr_scanner_v3.svelte index 68208c41..7bcde1fb 100644 --- a/src/lib/elements/element_qr_scanner_v3.svelte +++ b/src/lib/elements/element_qr_scanner_v3.svelte @@ -15,6 +15,7 @@ */ import { onDestroy, untrack } from 'svelte'; import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'; + import { RefreshCw } from '@lucide/svelte'; interface Props { start_qr_scanner?: boolean; @@ -36,6 +37,7 @@ let scanner: Html5Qrcode | null = null; let status = $state<'idle' | 'starting' | 'scanning' | 'error'>('idle'); let error_msg = $state(''); + let start_timeout: ReturnType | null = null; // React to start_qr_scanner prop changes from the parent $effect(() => { @@ -51,14 +53,31 @@ }); 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], @@ -79,8 +98,10 @@ 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.'; @@ -92,6 +113,7 @@ } async function stop_scanning() { + clear_start_timeout(); if (!scanner) return; const s = scanner; scanner = null; @@ -120,22 +142,38 @@ } + + {#if status === 'starting'} - - Starting camera... + + + Starting camera... + {:else if status === 'error'} - - {error_msg} + + {error_msg} + + Try Again diff --git a/src/lib/stores/ae_events_stores__leads_defaults.ts b/src/lib/stores/ae_events_stores__leads_defaults.ts index fd66b89f..a3d1d5ea 100644 --- a/src/lib/stores/ae_events_stores__leads_defaults.ts +++ b/src/lib/stores/ae_events_stores__leads_defaults.ts @@ -34,6 +34,14 @@ export interface LeadsLocState { edit_license_li: boolean; // Key = exhibit ID (random), value = last-used tab name. tab: Record; + // Per-exhibit Add tab input mode: 'qr' | 'search'. Persisted so operator preference survives navigation. + tab_add_mode: Record; + // Per-exhibit scan qualify mode: + // 'rapid' โ confirm tap โ auto-reset โ scan next + // 'qualify' โ confirm tap โ navigate to lead detail + // 'auto' โ no confirm, auto-add immediately โ auto-reset + // 'multi' โ BarcodeDetector batch scan, grid of confirm cards + tab_scan_qualify: Record; } export interface TmpLicense { @@ -110,7 +118,9 @@ export const leads_loc_defaults: LeadsLocState = { // Per-exhibit current tab. Key = exhibit ID (random), value = tab name. // Intentionally persisted so each exhibit's last-used tab is remembered across sessions. // Example: {'LNDF-67-89-92': 'start', 'OFLN-32-38-14': 'add_scan'} - tab: {} + tab: {}, + tab_add_mode: {}, + tab_scan_qualify: {} }; // In-memory leads state โ resets on page load. diff --git a/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte index 6bf9d416..0f78acd2 100644 --- a/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte +++ b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte @@ -16,12 +16,12 @@ 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 { CircleAlert, CircleCheck, Eye, LoaderCircle, ShieldOff, UserPlus } from '@lucide/svelte'; + import { Camera, CircleAlert, CircleCheck, Eye, LoaderCircle, RefreshCw, ShieldOff, UserPlus, X } from '@lucide/svelte'; import type { ae_EventBadge } from '$lib/types/ae_types'; interface Props { exhibit_id: string; - scan_qualify?: 'rapid' | 'qualify'; + scan_qualify?: 'rapid' | 'qualify' | 'auto'; on_lead_added?: (badge: ae_EventBadge) => void; } @@ -80,6 +80,11 @@ if (scanning_status === 'found' && found_badge?.allow_tracking !== true) { scanning_status = 'tracking_blocked'; } + + // Auto mode: skip the confirm card โ add immediately if tracking is allowed. + if (scanning_status === 'found' && scan_qualify === 'auto') { + await confirm_add_lead(); + } } catch (e) { console.error('Failed to load badge info', e); } @@ -153,8 +158,7 @@ Tracking Opt-Out {found_badge?.full_name || 'Attendee'} - This attendee has not opted in to exhibitor lead tracking - (allow_tracking is not set on their badge). + This attendee has opted out of exhibitor lead scanning. + Scan Next @@ -188,59 +193,81 @@ class="btn btn-sm w-full opacity-50" onclick={reset_scanner} > + Scan Next {:else if scanning_status === 'found' || scanning_status === 'adding'} - + + + {found_badge?.full_name || 'Badge Found'} {found_badge?.affiliations || ''} - - {#if scanning_status === 'adding'} - - Adding Lead... - {:else} - - Add as Lead - {/if} - + {#if scan_qualify === 'auto'} + + + + Auto-adding... + + {:else} + + {#if scanning_status === 'adding'} + + Adding Lead... + {:else} + + Add as Lead + {/if} + - - Cancel / Scan Again - + + + Cancel / Scan Again + + {/if} {:else if scanning_status === 'success'} - - - - Lead Added! - {found_badge?.full_name} + + + + + Lead Added! + {found_badge?.full_name} + + + {#if new_tracking_id} + + + View Details + + {/if} + Scanning next in 2 seconds... + + + + - - {#if new_tracking_id} - - View Details - - {/if} - Resetting scanner... {:else if scanning_status === 'error'} @@ -248,8 +275,21 @@ {error_msg} + Try Again {/if} - \ No newline at end of file + + + \ No newline at end of file diff --git a/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte new file mode 100644 index 00000000..d8be603c --- /dev/null +++ b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte @@ -0,0 +1,444 @@ + + + + + {#if !is_supported} + + + + Multi-Scan Not Available + + Multi-scan uses the browser's BarcodeDetector API, which is supported in + Chrome, Edge, and Safari 17+. Firefox support is coming soon. + + Use Rapid or Auto mode in the meantime. + + + {:else} + + + + + + + + + {#if camera_status === 'live'} + + + Align up to 4 badges flat in frame + + + + + + + + + + {/if} + + + {#if camera_status === 'starting'} + + + Starting camera... + + + {/if} + + + {#if camera_status === 'error'} + + {camera_error} + + + Try Again + + + {/if} + + + + {#if camera_status === 'live' || camera_status === 'capturing'} + + {#if camera_status === 'capturing'} + + Scanning... + {:else} + + Capture Batch + {/if} + + {/if} + + + {#if batch.length > 0} + + {#each batch as item (item.id)} + + {#if item.status === 'loading'} + + + + + + + + {:else if item.status === 'blocked'} + + + + + {item.badge?.full_name || 'Attendee'} + Opted out of lead scanning + + + dismiss_item(item)} + > + + OK, Dismiss + + + {:else if item.status === 'already_added'} + + + + {item.badge?.full_name || 'Attendee'} + Already captured + + + + + + View + + dismiss_item(item)} + > + + OK + + + + {:else if item.status === 'ready'} + + {item.badge?.full_name || 'Badge Found'} + {item.badge?.affiliations || ''} + + + add_lead(item)} + > + + Add + + dismiss_item(item)} + > + + + + + {:else if item.status === 'adding'} + + + + {item.badge?.full_name || '...'} + Adding... + + + + {:else if item.status === 'added'} + + + + {item.badge?.full_name || 'Lead'} + Lead added! + + + + {:else if item.status === 'error'} + + Failed to add + {item.badge?.full_name || 'Unknown'} + + dismiss_item(item)} + > + + Dismiss + + {/if} + + {/each} + + + + {#if ready_count > 0} + + + Add All ({ready_count}) + + {/if} + {/if} + + {/if} + + + diff --git a/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte index 09c37edc..7d79cad6 100644 --- a/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte +++ b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte @@ -5,12 +5,15 @@ * * Two orthogonal toggles: * - mode: 'qr' | 'search' โ how to find the attendee - * - scan_qualify: 'rapid' | 'qualify' โ what to do after adding (QR mode only) - * - rapid: auto-reset scanner โ scan next person immediately - * - qualify: navigate to lead detail โ fill qualifiers/notes right away + * - scan_qualify: 'rapid' | 'qualify' | 'auto' | 'multi' โ what to do after finding (QR mode only) + * rapid: confirm tap โ auto-reset โ scan next person immediately + * qualify: confirm tap โ navigate to lead detail โ fill qualifiers/notes + * auto: no confirm โ badge is added immediately on scan โ auto-reset + * multi: BarcodeDetector batch scan โ grid of confirm cards */ - import { ClipboardList, QrCode, Search, Zap } from '@lucide/svelte'; + import { Bot, ChevronDown, ClipboardList, 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'; import { events_loc } from '$lib/stores/ae_events_stores'; @@ -28,79 +31,132 @@ $events_loc.leads.tab_add_mode[exhibit_id] = new_mode; } - // Rapid vs Qualify scan mode (persisted per exhibit) - let scan_qualify = $derived($events_loc.leads.tab_scan_qualify?.[exhibit_id] ?? 'rapid'); + // 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); - function set_scan_qualify(new_mode: 'rapid' | 'qualify') { + function set_scan_qualify(new_mode: ScanQualifyMode) { if (!$events_loc.leads.tab_scan_qualify) $events_loc.leads.tab_scan_qualify = {}; $events_loc.leads.tab_scan_qualify[exhibit_id] = new_mode; + show_mode_opts = false; } function handle_lead_added(badge: any) { $events_loc.leads.tracking__search_version++; } + + // Mode selector expand/collapse + let show_mode_opts = $state(false); + + // Mode config โ drives both the trigger display and the options grid + const qr_modes: Array<{ + value: ScanQualifyMode; + label: string; + 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 }, + ]; + + let active_mode = $derived(qr_modes.find(m => m.value === scan_qualify) ?? qr_modes[0]); - + - - - - - set_mode(mode === 'qr' ? 'search' : 'qr')} - > - {#if mode === 'qr'} - - Manual Search - {:else} - - QR Scan - {/if} - - - + + set_mode(mode === 'qr' ? 'search' : 'qr')} + > {#if mode === 'qr'} - - set_scan_qualify('rapid')} - title="Rapid Scan โ reset immediately and scan the next person" - > - - - set_scan_qualify('qualify')} - title="Qualify Mode โ open lead detail after adding to fill in notes" - > - - - + + Switch to Manual Search + {:else} + + Switch to QR Scan {/if} - + - + {#if mode === 'qr'} - - {scan_qualify === 'rapid' ? 'Rapid Scan โ scan next after adding' : 'Qualify Mode โ open lead detail after adding'} - + + + + show_mode_opts = !show_mode_opts} + title="Change scan mode" + > + + + + + + + + {active_mode.label} + {active_mode.desc} + + + + + + + + {#if show_mode_opts} + + {#each qr_modes as m} + set_scan_qualify(m.value)} + > + + + + {m.label} + {m.desc} + + {/each} + + {/if} + {/if} {#if mode === 'qr'} - + {#if scan_qualify === 'multi'} + + {:else} + + {/if} {:else} {/if} - \ No newline at end of file +
` or `` displaying user-entered multi-line text (preserves breaks without horizontal overflow). + +--- + +## 12. Known Issues & Workarounds + +### `btn` + `preset-filled-*` resolves to transparent inside `card` components + +**Symptom:** A button using `btn preset-filled-primary` (or any `preset-filled-*`) inside a `card` div renders with `background-color: transparent`, making it invisible against the card surface. + +**Root cause:** The Skeleton v4 `btn` class sets a transparent background via a CSS variable chain. When nested inside a `card` element, the `preset-filled-*` class fails to win the specificity battle and the button appears invisible. This affects both light and dark mode. + +**Workaround:** Skip `btn` and `preset-filled-*` entirely for buttons inside `card` elements. Use direct Tailwind token classes instead: + +```svelte + + + ... + + + + + ... + + + +... +``` + +**Scope:** `btn` + `preset-*` classes work correctly on standalone buttons (e.g. page headers, nav bars). The issue is specific to the `card` component context. If we migrate away from Skeleton `card`/`btn`, this issue goes away. diff --git a/src/lib/elements/element_qr_scanner_v3.svelte b/src/lib/elements/element_qr_scanner_v3.svelte index 68208c41..7bcde1fb 100644 --- a/src/lib/elements/element_qr_scanner_v3.svelte +++ b/src/lib/elements/element_qr_scanner_v3.svelte @@ -15,6 +15,7 @@ */ import { onDestroy, untrack } from 'svelte'; import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'; + import { RefreshCw } from '@lucide/svelte'; interface Props { start_qr_scanner?: boolean; @@ -36,6 +37,7 @@ let scanner: Html5Qrcode | null = null; let status = $state<'idle' | 'starting' | 'scanning' | 'error'>('idle'); let error_msg = $state(''); + let start_timeout: ReturnType | null = null; // React to start_qr_scanner prop changes from the parent $effect(() => { @@ -51,14 +53,31 @@ }); 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], @@ -79,8 +98,10 @@ 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.'; @@ -92,6 +113,7 @@ } async function stop_scanning() { + clear_start_timeout(); if (!scanner) return; const s = scanner; scanner = null; @@ -120,22 +142,38 @@ } + + {#if status === 'starting'} - - Starting camera... + + + Starting camera... + {:else if status === 'error'} - - {error_msg} + + {error_msg} + + Try Again diff --git a/src/lib/stores/ae_events_stores__leads_defaults.ts b/src/lib/stores/ae_events_stores__leads_defaults.ts index fd66b89f..a3d1d5ea 100644 --- a/src/lib/stores/ae_events_stores__leads_defaults.ts +++ b/src/lib/stores/ae_events_stores__leads_defaults.ts @@ -34,6 +34,14 @@ export interface LeadsLocState { edit_license_li: boolean; // Key = exhibit ID (random), value = last-used tab name. tab: Record; + // Per-exhibit Add tab input mode: 'qr' | 'search'. Persisted so operator preference survives navigation. + tab_add_mode: Record; + // Per-exhibit scan qualify mode: + // 'rapid' โ confirm tap โ auto-reset โ scan next + // 'qualify' โ confirm tap โ navigate to lead detail + // 'auto' โ no confirm, auto-add immediately โ auto-reset + // 'multi' โ BarcodeDetector batch scan, grid of confirm cards + tab_scan_qualify: Record; } export interface TmpLicense { @@ -110,7 +118,9 @@ export const leads_loc_defaults: LeadsLocState = { // Per-exhibit current tab. Key = exhibit ID (random), value = tab name. // Intentionally persisted so each exhibit's last-used tab is remembered across sessions. // Example: {'LNDF-67-89-92': 'start', 'OFLN-32-38-14': 'add_scan'} - tab: {} + tab: {}, + tab_add_mode: {}, + tab_scan_qualify: {} }; // In-memory leads state โ resets on page load. diff --git a/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte index 6bf9d416..0f78acd2 100644 --- a/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte +++ b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte @@ -16,12 +16,12 @@ 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 { CircleAlert, CircleCheck, Eye, LoaderCircle, ShieldOff, UserPlus } from '@lucide/svelte'; + import { Camera, CircleAlert, CircleCheck, Eye, LoaderCircle, RefreshCw, ShieldOff, UserPlus, X } from '@lucide/svelte'; import type { ae_EventBadge } from '$lib/types/ae_types'; interface Props { exhibit_id: string; - scan_qualify?: 'rapid' | 'qualify'; + scan_qualify?: 'rapid' | 'qualify' | 'auto'; on_lead_added?: (badge: ae_EventBadge) => void; } @@ -80,6 +80,11 @@ if (scanning_status === 'found' && found_badge?.allow_tracking !== true) { scanning_status = 'tracking_blocked'; } + + // Auto mode: skip the confirm card โ add immediately if tracking is allowed. + if (scanning_status === 'found' && scan_qualify === 'auto') { + await confirm_add_lead(); + } } catch (e) { console.error('Failed to load badge info', e); } @@ -153,8 +158,7 @@ Tracking Opt-Out {found_badge?.full_name || 'Attendee'} - This attendee has not opted in to exhibitor lead tracking - (allow_tracking is not set on their badge). + This attendee has opted out of exhibitor lead scanning. + Scan Next @@ -188,59 +193,81 @@ class="btn btn-sm w-full opacity-50" onclick={reset_scanner} > + Scan Next {:else if scanning_status === 'found' || scanning_status === 'adding'} - + + + {found_badge?.full_name || 'Badge Found'} {found_badge?.affiliations || ''} - - {#if scanning_status === 'adding'} - - Adding Lead... - {:else} - - Add as Lead - {/if} - + {#if scan_qualify === 'auto'} + + + + Auto-adding... + + {:else} + + {#if scanning_status === 'adding'} + + Adding Lead... + {:else} + + Add as Lead + {/if} + - - Cancel / Scan Again - + + + Cancel / Scan Again + + {/if} {:else if scanning_status === 'success'} - - - - Lead Added! - {found_badge?.full_name} + + + + + Lead Added! + {found_badge?.full_name} + + + {#if new_tracking_id} + + + View Details + + {/if} + Scanning next in 2 seconds... + + + + - - {#if new_tracking_id} - - View Details - - {/if} - Resetting scanner... {:else if scanning_status === 'error'} @@ -248,8 +275,21 @@ {error_msg} + Try Again {/if} - \ No newline at end of file + + + \ No newline at end of file diff --git a/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte new file mode 100644 index 00000000..d8be603c --- /dev/null +++ b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte @@ -0,0 +1,444 @@ + + + + + {#if !is_supported} + + + + Multi-Scan Not Available + + Multi-scan uses the browser's BarcodeDetector API, which is supported in + Chrome, Edge, and Safari 17+. Firefox support is coming soon. + + Use Rapid or Auto mode in the meantime. + + + {:else} + + + + + + + + + {#if camera_status === 'live'} + + + Align up to 4 badges flat in frame + + + + + + + + + + {/if} + + + {#if camera_status === 'starting'} + + + Starting camera... + + + {/if} + + + {#if camera_status === 'error'} + + {camera_error} + + + Try Again + + + {/if} + + + + {#if camera_status === 'live' || camera_status === 'capturing'} + + {#if camera_status === 'capturing'} + + Scanning... + {:else} + + Capture Batch + {/if} + + {/if} + + + {#if batch.length > 0} + + {#each batch as item (item.id)} + + {#if item.status === 'loading'} + + + + + + + + {:else if item.status === 'blocked'} + + + + + {item.badge?.full_name || 'Attendee'} + Opted out of lead scanning + + + dismiss_item(item)} + > + + OK, Dismiss + + + {:else if item.status === 'already_added'} + + + + {item.badge?.full_name || 'Attendee'} + Already captured + + + + + + View + + dismiss_item(item)} + > + + OK + + + + {:else if item.status === 'ready'} + + {item.badge?.full_name || 'Badge Found'} + {item.badge?.affiliations || ''} + + + add_lead(item)} + > + + Add + + dismiss_item(item)} + > + + + + + {:else if item.status === 'adding'} + + + + {item.badge?.full_name || '...'} + Adding... + + + + {:else if item.status === 'added'} + + + + {item.badge?.full_name || 'Lead'} + Lead added! + + + + {:else if item.status === 'error'} + + Failed to add + {item.badge?.full_name || 'Unknown'} + + dismiss_item(item)} + > + + Dismiss + + {/if} + + {/each} + + + + {#if ready_count > 0} + + + Add All ({ready_count}) + + {/if} + {/if} + + {/if} + + + diff --git a/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte index 09c37edc..7d79cad6 100644 --- a/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte +++ b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte @@ -5,12 +5,15 @@ * * Two orthogonal toggles: * - mode: 'qr' | 'search' โ how to find the attendee - * - scan_qualify: 'rapid' | 'qualify' โ what to do after adding (QR mode only) - * - rapid: auto-reset scanner โ scan next person immediately - * - qualify: navigate to lead detail โ fill qualifiers/notes right away + * - scan_qualify: 'rapid' | 'qualify' | 'auto' | 'multi' โ what to do after finding (QR mode only) + * rapid: confirm tap โ auto-reset โ scan next person immediately + * qualify: confirm tap โ navigate to lead detail โ fill qualifiers/notes + * auto: no confirm โ badge is added immediately on scan โ auto-reset + * multi: BarcodeDetector batch scan โ grid of confirm cards */ - import { ClipboardList, QrCode, Search, Zap } from '@lucide/svelte'; + import { Bot, ChevronDown, ClipboardList, 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'; import { events_loc } from '$lib/stores/ae_events_stores'; @@ -28,79 +31,132 @@ $events_loc.leads.tab_add_mode[exhibit_id] = new_mode; } - // Rapid vs Qualify scan mode (persisted per exhibit) - let scan_qualify = $derived($events_loc.leads.tab_scan_qualify?.[exhibit_id] ?? 'rapid'); + // 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); - function set_scan_qualify(new_mode: 'rapid' | 'qualify') { + function set_scan_qualify(new_mode: ScanQualifyMode) { if (!$events_loc.leads.tab_scan_qualify) $events_loc.leads.tab_scan_qualify = {}; $events_loc.leads.tab_scan_qualify[exhibit_id] = new_mode; + show_mode_opts = false; } function handle_lead_added(badge: any) { $events_loc.leads.tracking__search_version++; } + + // Mode selector expand/collapse + let show_mode_opts = $state(false); + + // Mode config โ drives both the trigger display and the options grid + const qr_modes: Array<{ + value: ScanQualifyMode; + label: string; + 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 }, + ]; + + let active_mode = $derived(qr_modes.find(m => m.value === scan_qualify) ?? qr_modes[0]); - + - - - - - set_mode(mode === 'qr' ? 'search' : 'qr')} - > - {#if mode === 'qr'} - - Manual Search - {:else} - - QR Scan - {/if} - - - + + set_mode(mode === 'qr' ? 'search' : 'qr')} + > {#if mode === 'qr'} - - set_scan_qualify('rapid')} - title="Rapid Scan โ reset immediately and scan the next person" - > - - - set_scan_qualify('qualify')} - title="Qualify Mode โ open lead detail after adding to fill in notes" - > - - - + + Switch to Manual Search + {:else} + + Switch to QR Scan {/if} - + - + {#if mode === 'qr'} - - {scan_qualify === 'rapid' ? 'Rapid Scan โ scan next after adding' : 'Qualify Mode โ open lead detail after adding'} - + + + + show_mode_opts = !show_mode_opts} + title="Change scan mode" + > + + + + + + + + {active_mode.label} + {active_mode.desc} + + + + + + + + {#if show_mode_opts} + + {#each qr_modes as m} + set_scan_qualify(m.value)} + > + + + + {m.label} + {m.desc} + + {/each} + + {/if} + {/if} {#if mode === 'qr'} - + {#if scan_qualify === 'multi'} + + {:else} + + {/if} {:else} {/if} - \ No newline at end of file +
` displaying user-entered multi-line text (preserves breaks without horizontal overflow). + +--- + +## 12. Known Issues & Workarounds + +### `btn` + `preset-filled-*` resolves to transparent inside `card` components + +**Symptom:** A button using `btn preset-filled-primary` (or any `preset-filled-*`) inside a `card` div renders with `background-color: transparent`, making it invisible against the card surface. + +**Root cause:** The Skeleton v4 `btn` class sets a transparent background via a CSS variable chain. When nested inside a `card` element, the `preset-filled-*` class fails to win the specificity battle and the button appears invisible. This affects both light and dark mode. + +**Workaround:** Skip `btn` and `preset-filled-*` entirely for buttons inside `card` elements. Use direct Tailwind token classes instead: + +```svelte + + + ... + + + + + ... + + + +... +``` + +**Scope:** `btn` + `preset-*` classes work correctly on standalone buttons (e.g. page headers, nav bars). The issue is specific to the `card` component context. If we migrate away from Skeleton `card`/`btn`, this issue goes away. diff --git a/src/lib/elements/element_qr_scanner_v3.svelte b/src/lib/elements/element_qr_scanner_v3.svelte index 68208c41..7bcde1fb 100644 --- a/src/lib/elements/element_qr_scanner_v3.svelte +++ b/src/lib/elements/element_qr_scanner_v3.svelte @@ -15,6 +15,7 @@ */ import { onDestroy, untrack } from 'svelte'; import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode'; + import { RefreshCw } from '@lucide/svelte'; interface Props { start_qr_scanner?: boolean; @@ -36,6 +37,7 @@ let scanner: Html5Qrcode | null = null; let status = $state<'idle' | 'starting' | 'scanning' | 'error'>('idle'); let error_msg = $state(''); + let start_timeout: ReturnType | null = null; // React to start_qr_scanner prop changes from the parent $effect(() => { @@ -51,14 +53,31 @@ }); 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], @@ -79,8 +98,10 @@ 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.'; @@ -92,6 +113,7 @@ } async function stop_scanning() { + clear_start_timeout(); if (!scanner) return; const s = scanner; scanner = null; @@ -120,22 +142,38 @@ } + + {#if status === 'starting'} - - Starting camera... + + + Starting camera... + {:else if status === 'error'} - - {error_msg} + + {error_msg} + + Try Again diff --git a/src/lib/stores/ae_events_stores__leads_defaults.ts b/src/lib/stores/ae_events_stores__leads_defaults.ts index fd66b89f..a3d1d5ea 100644 --- a/src/lib/stores/ae_events_stores__leads_defaults.ts +++ b/src/lib/stores/ae_events_stores__leads_defaults.ts @@ -34,6 +34,14 @@ export interface LeadsLocState { edit_license_li: boolean; // Key = exhibit ID (random), value = last-used tab name. tab: Record; + // Per-exhibit Add tab input mode: 'qr' | 'search'. Persisted so operator preference survives navigation. + tab_add_mode: Record; + // Per-exhibit scan qualify mode: + // 'rapid' โ confirm tap โ auto-reset โ scan next + // 'qualify' โ confirm tap โ navigate to lead detail + // 'auto' โ no confirm, auto-add immediately โ auto-reset + // 'multi' โ BarcodeDetector batch scan, grid of confirm cards + tab_scan_qualify: Record; } export interface TmpLicense { @@ -110,7 +118,9 @@ export const leads_loc_defaults: LeadsLocState = { // Per-exhibit current tab. Key = exhibit ID (random), value = tab name. // Intentionally persisted so each exhibit's last-used tab is remembered across sessions. // Example: {'LNDF-67-89-92': 'start', 'OFLN-32-38-14': 'add_scan'} - tab: {} + tab: {}, + tab_add_mode: {}, + tab_scan_qualify: {} }; // In-memory leads state โ resets on page load. diff --git a/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte index 6bf9d416..0f78acd2 100644 --- a/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte +++ b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte @@ -16,12 +16,12 @@ 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 { CircleAlert, CircleCheck, Eye, LoaderCircle, ShieldOff, UserPlus } from '@lucide/svelte'; + import { Camera, CircleAlert, CircleCheck, Eye, LoaderCircle, RefreshCw, ShieldOff, UserPlus, X } from '@lucide/svelte'; import type { ae_EventBadge } from '$lib/types/ae_types'; interface Props { exhibit_id: string; - scan_qualify?: 'rapid' | 'qualify'; + scan_qualify?: 'rapid' | 'qualify' | 'auto'; on_lead_added?: (badge: ae_EventBadge) => void; } @@ -80,6 +80,11 @@ if (scanning_status === 'found' && found_badge?.allow_tracking !== true) { scanning_status = 'tracking_blocked'; } + + // Auto mode: skip the confirm card โ add immediately if tracking is allowed. + if (scanning_status === 'found' && scan_qualify === 'auto') { + await confirm_add_lead(); + } } catch (e) { console.error('Failed to load badge info', e); } @@ -153,8 +158,7 @@ Tracking Opt-Out {found_badge?.full_name || 'Attendee'} - This attendee has not opted in to exhibitor lead tracking - (allow_tracking is not set on their badge). + This attendee has opted out of exhibitor lead scanning. + Scan Next @@ -188,59 +193,81 @@ class="btn btn-sm w-full opacity-50" onclick={reset_scanner} > + Scan Next {:else if scanning_status === 'found' || scanning_status === 'adding'} - + + + {found_badge?.full_name || 'Badge Found'} {found_badge?.affiliations || ''} - - {#if scanning_status === 'adding'} - - Adding Lead... - {:else} - - Add as Lead - {/if} - + {#if scan_qualify === 'auto'} + + + + Auto-adding... + + {:else} + + {#if scanning_status === 'adding'} + + Adding Lead... + {:else} + + Add as Lead + {/if} + - - Cancel / Scan Again - + + + Cancel / Scan Again + + {/if} {:else if scanning_status === 'success'} - - - - Lead Added! - {found_badge?.full_name} + + + + + Lead Added! + {found_badge?.full_name} + + + {#if new_tracking_id} + + + View Details + + {/if} + Scanning next in 2 seconds... + + + + - - {#if new_tracking_id} - - View Details - - {/if} - Resetting scanner... {:else if scanning_status === 'error'} @@ -248,8 +275,21 @@ {error_msg} + Try Again {/if} - \ No newline at end of file + + + \ No newline at end of file diff --git a/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte new file mode 100644 index 00000000..d8be603c --- /dev/null +++ b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte @@ -0,0 +1,444 @@ + + + + + {#if !is_supported} + + + + Multi-Scan Not Available + + Multi-scan uses the browser's BarcodeDetector API, which is supported in + Chrome, Edge, and Safari 17+. Firefox support is coming soon. + + Use Rapid or Auto mode in the meantime. + + + {:else} + + + + + + + + + {#if camera_status === 'live'} + + + Align up to 4 badges flat in frame + + + + + + + + + + {/if} + + + {#if camera_status === 'starting'} + + + Starting camera... + + + {/if} + + + {#if camera_status === 'error'} + + {camera_error} + + + Try Again + + + {/if} + + + + {#if camera_status === 'live' || camera_status === 'capturing'} + + {#if camera_status === 'capturing'} + + Scanning... + {:else} + + Capture Batch + {/if} + + {/if} + + + {#if batch.length > 0} + + {#each batch as item (item.id)} + + {#if item.status === 'loading'} + + + + + + + + {:else if item.status === 'blocked'} + + + + + {item.badge?.full_name || 'Attendee'} + Opted out of lead scanning + + + dismiss_item(item)} + > + + OK, Dismiss + + + {:else if item.status === 'already_added'} + + + + {item.badge?.full_name || 'Attendee'} + Already captured + + + + + + View + + dismiss_item(item)} + > + + OK + + + + {:else if item.status === 'ready'} + + {item.badge?.full_name || 'Badge Found'} + {item.badge?.affiliations || ''} + + + add_lead(item)} + > + + Add + + dismiss_item(item)} + > + + + + + {:else if item.status === 'adding'} + + + + {item.badge?.full_name || '...'} + Adding... + + + + {:else if item.status === 'added'} + + + + {item.badge?.full_name || 'Lead'} + Lead added! + + + + {:else if item.status === 'error'} + + Failed to add + {item.badge?.full_name || 'Unknown'} + + dismiss_item(item)} + > + + Dismiss + + {/if} + + {/each} + + + + {#if ready_count > 0} + + + Add All ({ready_count}) + + {/if} + {/if} + + {/if} + + + diff --git a/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte index 09c37edc..7d79cad6 100644 --- a/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte +++ b/src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte @@ -5,12 +5,15 @@ * * Two orthogonal toggles: * - mode: 'qr' | 'search' โ how to find the attendee - * - scan_qualify: 'rapid' | 'qualify' โ what to do after adding (QR mode only) - * - rapid: auto-reset scanner โ scan next person immediately - * - qualify: navigate to lead detail โ fill qualifiers/notes right away + * - scan_qualify: 'rapid' | 'qualify' | 'auto' | 'multi' โ what to do after finding (QR mode only) + * rapid: confirm tap โ auto-reset โ scan next person immediately + * qualify: confirm tap โ navigate to lead detail โ fill qualifiers/notes + * auto: no confirm โ badge is added immediately on scan โ auto-reset + * multi: BarcodeDetector batch scan โ grid of confirm cards */ - import { ClipboardList, QrCode, Search, Zap } from '@lucide/svelte'; + import { Bot, ChevronDown, ClipboardList, 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'; import { events_loc } from '$lib/stores/ae_events_stores'; @@ -28,79 +31,132 @@ $events_loc.leads.tab_add_mode[exhibit_id] = new_mode; } - // Rapid vs Qualify scan mode (persisted per exhibit) - let scan_qualify = $derived($events_loc.leads.tab_scan_qualify?.[exhibit_id] ?? 'rapid'); + // 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); - function set_scan_qualify(new_mode: 'rapid' | 'qualify') { + function set_scan_qualify(new_mode: ScanQualifyMode) { if (!$events_loc.leads.tab_scan_qualify) $events_loc.leads.tab_scan_qualify = {}; $events_loc.leads.tab_scan_qualify[exhibit_id] = new_mode; + show_mode_opts = false; } function handle_lead_added(badge: any) { $events_loc.leads.tracking__search_version++; } + + // Mode selector expand/collapse + let show_mode_opts = $state(false); + + // Mode config โ drives both the trigger display and the options grid + const qr_modes: Array<{ + value: ScanQualifyMode; + label: string; + 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 }, + ]; + + let active_mode = $derived(qr_modes.find(m => m.value === scan_qualify) ?? qr_modes[0]); - + - - - - - set_mode(mode === 'qr' ? 'search' : 'qr')} - > - {#if mode === 'qr'} - - Manual Search - {:else} - - QR Scan - {/if} - - - + + set_mode(mode === 'qr' ? 'search' : 'qr')} + > {#if mode === 'qr'} - - set_scan_qualify('rapid')} - title="Rapid Scan โ reset immediately and scan the next person" - > - - - set_scan_qualify('qualify')} - title="Qualify Mode โ open lead detail after adding to fill in notes" - > - - - + + Switch to Manual Search + {:else} + + Switch to QR Scan {/if} - + - + {#if mode === 'qr'} - - {scan_qualify === 'rapid' ? 'Rapid Scan โ scan next after adding' : 'Qualify Mode โ open lead detail after adding'} - + + + + show_mode_opts = !show_mode_opts} + title="Change scan mode" + > + + + + + + + + {active_mode.label} + {active_mode.desc} + + + + + + + + {#if show_mode_opts} + + {#each qr_modes as m} + set_scan_qualify(m.value)} + > + + + + {m.label} + {m.desc} + + {/each} + + {/if} + {/if} {#if mode === 'qr'} - + {#if scan_qualify === 'multi'} + + {:else} + + {/if} {:else} {/if} - \ No newline at end of file +
Starting camera...
{error_msg}
{found_badge?.full_name || 'Attendee'}
- This attendee has not opted in to exhibitor lead tracking - (allow_tracking is not set on their badge). + This attendee has opted out of exhibitor lead scanning.
allow_tracking
{found_badge?.affiliations || ''}
{found_badge?.full_name}
Scanning next in 2 seconds...
Resetting scanner...
+ Multi-scan uses the browser's BarcodeDetector API, which is supported in + Chrome, Edge, and Safari 17+. Firefox support is coming soon. +
Use Rapid or Auto mode in the meantime.
{camera_error}
{item.badge?.full_name || 'Attendee'}
Opted out of lead scanning
Already captured
{item.badge?.full_name || 'Badge Found'}
{item.badge?.affiliations || ''}
{item.badge?.full_name || '...'}
Adding...
{item.badge?.full_name || 'Lead'}
Lead added!
Failed to add
{item.badge?.full_name || 'Unknown'}
- {scan_qualify === 'rapid' ? 'Rapid Scan โ scan next after adding' : 'Qualify Mode โ open lead detail after adding'} -