feat: leads QR scanner — Auto/Multi modes, 4-mode fancy selector, UX polish
Scanner modes (now 4, persisted per exhibit): - Rapid: confirm tap → auto-reset (existing, fixed) - Qualify: confirm tap → navigate to lead detail (existing, fixed) - Auto: badge found → auto-add immediately, no confirmation tap needed - Multi: BarcodeDetector batch scan → responsive grid of confirm cards Multi scanner (new ae_comp__lead_qr_scanner_multi.svelte): - Native BarcodeDetector API (Chrome/Edge/Safari 17+); Firefox fallback message - 16:9 viewfinder with corner guides + "Align up to 4 badges flat" overlay - Capture Batch tap → up to 8 QR codes detected in one frame - Per-card states: loading skeleton, ready (Add/Skip), blocked (opt-out), already-captured (View/OK), adding spinner, success (auto-fade), error - Add All (N) bulk action; cards fade+scale out smoothly on dismiss Mode selector (ae_tab__add.svelte): - Replaces Rapid/Qualify toggle with collapsible 4-mode fancy select - Trigger shows active mode icon (color-coded) + name + description - 2×2 options grid expands on tap, closes on selection QR scanner element (element_qr_scanner_v3.svelte): - object-fit: cover eliminates 4:3 camera letterbox dead zone - 7-second start timeout with actionable error message - Starting/error overlays with high-contrast styling - Try Again button with RefreshCw icon Style guide updated: icon+text button rule (§8), btn/preset-filled workaround (§12) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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., `<Calendar size="1em" />`).
|
||||
@@ -192,6 +193,7 @@ Always wrap in `{#if $lq__obj}{...}{:else}...skeleton...{/if}` — **never** sho
|
||||
| Form inputs | Visible `<label>` 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 `<pre>` or `<p>` 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
|
||||
<!-- ✅ Correct — works reliably inside cards -->
|
||||
<button class="w-full rounded-xl py-5 font-bold flex items-center justify-center gap-2
|
||||
bg-primary-500 text-white hover:brightness-110 transition-all cursor-pointer">
|
||||
...
|
||||
</button>
|
||||
|
||||
<!-- Secondary / cancel button inside a card -->
|
||||
<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">
|
||||
...
|
||||
</button>
|
||||
|
||||
<!-- ❌ Broken inside card — do not use -->
|
||||
<button class="btn btn-xl preset-filled-primary">...</button>
|
||||
```
|
||||
|
||||
**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.
|
||||
|
||||
@@ -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<typeof setTimeout> | 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 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* html5-qrcode injects a <video> with inline width/height. Override with cover so the
|
||||
camera stream fills the container completely on any camera aspect ratio (4:3, 16:9, etc.).
|
||||
Without this, portrait-mode mobile cameras leave a gray letterbox at the bottom. */
|
||||
:global(.qr-scanner-v3 video) {
|
||||
object-fit: cover !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 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 class="absolute inset-0 flex items-center justify-center bg-black/30 rounded-xl">
|
||||
<span class="bg-black/60 text-white text-sm font-semibold px-4 py-2 rounded-full animate-pulse shadow-lg">
|
||||
Starting camera...
|
||||
</span>
|
||||
</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>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-5 bg-black/75 rounded-xl p-6 text-center">
|
||||
<p class="text-white text-sm font-semibold leading-snug drop-shadow">{error_msg}</p>
|
||||
<!-- bg-white + dark text: always readable on the dark camera overlay regardless of theme.
|
||||
preset-filled-primary is theme-adaptive and has poor contrast on black/75 backgrounds. -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-filled-primary"
|
||||
class="bg-white text-surface-950 hover:bg-surface-100 font-bold text-base px-8 py-3 rounded-xl shadow-lg transition-colors cursor-pointer flex items-center gap-2"
|
||||
onclick={start_scanning}
|
||||
>
|
||||
<RefreshCw size="1.2em" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,14 @@ export interface LeadsLocState {
|
||||
edit_license_li: boolean;
|
||||
// Key = exhibit ID (random), value = last-used tab name.
|
||||
tab: Record<string, string>;
|
||||
// Per-exhibit Add tab input mode: 'qr' | 'search'. Persisted so operator preference survives navigation.
|
||||
tab_add_mode: Record<string, string>;
|
||||
// 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<string, 'rapid' | 'qualify' | 'auto' | 'multi'>;
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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 @@
|
||||
<h3 class="h3 font-bold">Tracking Opt-Out</h3>
|
||||
<p class="text-xl font-bold">{found_badge?.full_name || 'Attendee'}</p>
|
||||
<p class="opacity-70 text-sm">
|
||||
This attendee has not opted in to exhibitor lead tracking
|
||||
(<code>allow_tracking</code> is not set on their badge).
|
||||
This attendee has opted out of exhibitor lead scanning.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -162,6 +166,7 @@
|
||||
class="btn w-full preset-filled-warning font-bold"
|
||||
onclick={reset_scanner}
|
||||
>
|
||||
<Camera size="1.2em" />
|
||||
Scan Next
|
||||
</button>
|
||||
</div>
|
||||
@@ -188,59 +193,81 @@
|
||||
class="btn btn-sm w-full opacity-50"
|
||||
onclick={reset_scanner}
|
||||
>
|
||||
<Camera size="1em" />
|
||||
Scan Next
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if scanning_status === 'found' || scanning_status === 'adding'}
|
||||
<div class="card p-6 w-full max-w-md space-y-4 preset-tonal-primary shadow-xl border-2 border-primary-500">
|
||||
<!-- bg-surface-50-900: canonical card face token — near-white (light) / deep slate (dark).
|
||||
Explicit rather than preset-tonal-* so primary/surface buttons have guaranteed contrast. -->
|
||||
<!-- Buttons use direct Tailwind tokens, not btn/preset-*, because the Skeleton
|
||||
preset-filled chain resolves to transparent in this card context. -->
|
||||
<div class="card p-6 w-full max-w-md space-y-4 bg-surface-50-900 shadow-xl border-2 border-primary-500">
|
||||
<div class="text-center">
|
||||
<h3 class="h3 font-bold">{found_badge?.full_name || 'Badge Found'}</h3>
|
||||
<p class="opacity-70">{found_badge?.affiliations || ''}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xl w-full preset-filled-primary font-bold py-6"
|
||||
disabled={scanning_status === 'adding'}
|
||||
onclick={confirm_add_lead}
|
||||
>
|
||||
{#if scanning_status === 'adding'}
|
||||
<LoaderCircle class="animate-spin mr-2" size="1.5em" />
|
||||
Adding Lead...
|
||||
{:else}
|
||||
<UserPlus size="1.5em" class="mr-2" />
|
||||
Add as Lead
|
||||
{/if}
|
||||
</button>
|
||||
{#if scan_qualify === 'auto'}
|
||||
<!-- Auto mode: no confirm buttons — adding happens automatically -->
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm w-full opacity-50"
|
||||
disabled={scanning_status === 'adding'}
|
||||
onclick={reset_scanner}
|
||||
>
|
||||
Cancel / Scan Again
|
||||
</button>
|
||||
<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'}
|
||||
onclick={reset_scanner}
|
||||
>
|
||||
<X size="1em" />
|
||||
Cancel / Scan Again
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if scanning_status === 'success'}
|
||||
<div class="card p-10 w-full max-w-md flex flex-col items-center space-y-4 preset-tonal-success shadow-xl">
|
||||
<CircleCheck size="4em" class="text-success-500 animate-bounce" />
|
||||
<div class="text-center">
|
||||
<h3 class="h4 font-bold">Lead Added!</h3>
|
||||
<p class="text-xl font-bold">{found_badge?.full_name}</p>
|
||||
<div class="card w-full max-w-md flex flex-col items-center space-y-4 preset-tonal-success shadow-xl overflow-hidden">
|
||||
<div class="p-10 w-full flex flex-col items-center space-y-4">
|
||||
<CircleCheck size="4em" class="text-success-500 animate-bounce" />
|
||||
<div class="text-center">
|
||||
<h3 class="h4 font-bold">Lead Added!</h3>
|
||||
<p class="text-xl font-bold">{found_badge?.full_name}</p>
|
||||
</div>
|
||||
<!-- In rapid mode: offer a View Details escape hatch while the reset countdown runs -->
|
||||
{#if new_tracking_id}
|
||||
<a
|
||||
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`}
|
||||
class="btn btn-sm preset-outlined-surface w-full"
|
||||
>
|
||||
<Eye size="1em" />
|
||||
View Details
|
||||
</a>
|
||||
{/if}
|
||||
<p class="text-xs opacity-50 uppercase tracking-widest">Scanning next in 2 seconds...</p>
|
||||
</div>
|
||||
<!-- Countdown bar: pure CSS animation depletes over 2s (matching the setTimeout reset delay).
|
||||
Gives the operator a clear visual cue that the scanner is about to reset. -->
|
||||
<div class="w-full h-1.5 bg-success-200/40">
|
||||
<div class="h-full bg-success-500 scanner-reset-countdown"></div>
|
||||
</div>
|
||||
<!-- In rapid mode: offer a View Details escape hatch while the reset countdown runs -->
|
||||
{#if new_tracking_id}
|
||||
<a
|
||||
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`}
|
||||
class="btn btn-sm preset-outlined-surface w-full"
|
||||
>
|
||||
<Eye size="1em" class="mr-1" /> View Details
|
||||
</a>
|
||||
{/if}
|
||||
<p class="text-sm opacity-40">Resetting scanner...</p>
|
||||
</div>
|
||||
|
||||
{:else if scanning_status === 'error'}
|
||||
@@ -248,8 +275,21 @@
|
||||
<CircleAlert size="3em" class="text-error-500" />
|
||||
<p class="text-center font-bold">{error_msg}</p>
|
||||
<button type="button" class="btn btn-sm preset-filled-error" onclick={reset_scanner}>
|
||||
<RefreshCw size="1em" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Countdown bar for the rapid-mode success card.
|
||||
Must match the 2000ms setTimeout in reset_scanner(). */
|
||||
.scanner-reset-countdown {
|
||||
animation: scanner-reset-countdown 2s linear forwards;
|
||||
}
|
||||
@keyframes scanner-reset-countdown {
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,444 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte
|
||||
* Multi-badge batch QR scanner.
|
||||
*
|
||||
* Uses the native BarcodeDetector API (Chrome/Edge/Safari 17+) to detect multiple
|
||||
* QR codes in a single camera frame. Staff lay 1–4 badges flat in view, tap
|
||||
* "Capture Batch", and a grid of confirm cards appears — one per detected badge.
|
||||
* Cards can be individually added, skipped, or dismissed; "Add All" handles the batch.
|
||||
*
|
||||
* Hard cap: 8 badges per batch (browser/camera support varies; 4 is the practical ask).
|
||||
*
|
||||
* Firefox: BarcodeDetector not yet supported — shows an informative fallback.
|
||||
*/
|
||||
import { onDestroy } from 'svelte';
|
||||
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 { 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';
|
||||
import type { ae_EventBadge } from '$lib/types/ae_types';
|
||||
import {
|
||||
CircleCheck, Eye, Layers, LoaderCircle,
|
||||
RefreshCw, ScanLine, ShieldOff, UserPlus, X
|
||||
} from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
on_lead_added?: (badge: ae_EventBadge) => void;
|
||||
}
|
||||
|
||||
let { exhibit_id, on_lead_added }: Props = $props();
|
||||
|
||||
// BarcodeDetector is in Chrome/Edge/Safari 17+; not yet in Firefox.
|
||||
// Check at runtime — TypeScript lib.dom.d.ts may not have it yet.
|
||||
const is_supported = typeof window !== 'undefined' && 'BarcodeDetector' in window;
|
||||
|
||||
// --- Types ---
|
||||
type BatchStatus = 'loading' | 'ready' | 'blocked' | 'already_added' | 'adding' | 'added' | 'error';
|
||||
|
||||
interface BatchItem {
|
||||
id: string; // badge id_random from QR
|
||||
badge: ae_EventBadge | null;
|
||||
status: BatchStatus;
|
||||
existing_tracking_id: string; // set when status === 'already_added'
|
||||
dismissing: boolean; // true while CSS fade-out plays
|
||||
}
|
||||
|
||||
// --- Existing leads (duplicate detection) ---
|
||||
let existing_leads_map = $derived(
|
||||
liveQuery(async () => {
|
||||
const leads = await db_events.exhibit_tracking
|
||||
.where('event_exhibit_id').equals(exhibit_id).toArray();
|
||||
const map = new Map<string, string>();
|
||||
leads.forEach(l => {
|
||||
const b_id = l.event_badge_id_random || l.event_badge_id?.toString();
|
||||
if (b_id) map.set(b_id, l.event_exhibit_tracking_id_random || l.event_exhibit_tracking_id?.toString() || '');
|
||||
});
|
||||
return map;
|
||||
})
|
||||
);
|
||||
|
||||
// --- Camera ---
|
||||
let video_el = $state<HTMLVideoElement | undefined>(undefined);
|
||||
let stream: MediaStream | null = null;
|
||||
let detector: any = null;
|
||||
let camera_status = $state<'idle' | 'starting' | 'live' | 'capturing' | 'error'>('idle');
|
||||
let camera_error = $state('');
|
||||
|
||||
// Start camera when the video element mounts
|
||||
$effect(() => {
|
||||
if (!video_el || !is_supported) return;
|
||||
start_camera();
|
||||
return () => stop_camera();
|
||||
});
|
||||
|
||||
onDestroy(stop_camera);
|
||||
|
||||
async function start_camera() {
|
||||
if (camera_status !== 'idle') return;
|
||||
camera_status = 'starting';
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment', width: { ideal: 1920 }, height: { ideal: 1080 } }
|
||||
});
|
||||
if (!video_el) { stop_camera(); return; }
|
||||
video_el.srcObject = stream;
|
||||
await video_el.play();
|
||||
// BarcodeDetector API — not yet typed in lib.dom.d.ts for all targets
|
||||
detector = new (window as any).BarcodeDetector({ formats: ['qr_code'] });
|
||||
camera_status = 'live';
|
||||
} catch (e: any) {
|
||||
camera_status = 'error';
|
||||
camera_error = e?.name === 'NotAllowedError'
|
||||
? 'Camera access denied. Allow camera access and try again.'
|
||||
: 'Could not start camera. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
function stop_camera() {
|
||||
stream?.getTracks().forEach(t => t.stop());
|
||||
stream = null;
|
||||
detector = null;
|
||||
if (camera_status !== 'error') camera_status = 'idle';
|
||||
}
|
||||
|
||||
async function retry_camera() {
|
||||
camera_status = 'idle';
|
||||
await start_camera();
|
||||
}
|
||||
|
||||
// --- Batch ---
|
||||
let batch = $state<BatchItem[]>([]);
|
||||
let ready_count = $derived(batch.filter(i => i.status === 'ready' && !i.dismissing).length);
|
||||
|
||||
async function capture_batch() {
|
||||
if (!detector || !video_el || camera_status !== 'live') return;
|
||||
camera_status = 'capturing';
|
||||
|
||||
try {
|
||||
const barcodes: Array<{ rawValue: string }> = await detector.detect(video_el);
|
||||
const existing_ids = new Set(batch.map(i => i.id));
|
||||
|
||||
const new_objs = barcodes
|
||||
.map(b => ae_util.process_data_string(b.rawValue))
|
||||
.filter((obj): obj is { type: string; id: string } =>
|
||||
!!(obj && obj.type === 'event_badge' && obj.id))
|
||||
.filter(obj => !existing_ids.has(obj.id))
|
||||
.slice(0, Math.max(0, 8 - batch.length)); // hard cap at 8 total
|
||||
|
||||
for (const obj of new_objs) {
|
||||
const item: BatchItem = {
|
||||
id: obj.id,
|
||||
badge: null,
|
||||
status: 'loading',
|
||||
existing_tracking_id: '',
|
||||
dismissing: false
|
||||
};
|
||||
batch.push(item);
|
||||
load_badge(item); // fire-and-forget; updates item in place
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Multi] BarcodeDetector.detect failed:', e);
|
||||
}
|
||||
|
||||
camera_status = 'live';
|
||||
}
|
||||
|
||||
async function load_badge(item: BatchItem) {
|
||||
try {
|
||||
const badge = await events_func.load_ae_obj_id__event_badge({
|
||||
api_cfg: $ae_api,
|
||||
event_badge_id: item.id,
|
||||
log_lvl: 1
|
||||
});
|
||||
item.badge = badge;
|
||||
|
||||
if ($existing_leads_map?.has(item.id)) {
|
||||
item.status = 'already_added';
|
||||
item.existing_tracking_id = $existing_leads_map.get(item.id) ?? '';
|
||||
} else if (badge?.allow_tracking !== true) {
|
||||
// Attendee has opted out — show card so staff can inform them
|
||||
item.status = 'blocked';
|
||||
} else {
|
||||
item.status = 'ready';
|
||||
}
|
||||
} catch {
|
||||
item.status = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function add_lead(item: BatchItem) {
|
||||
if (item.status !== 'ready' || !item.badge?.event_badge_id_random) return;
|
||||
item.status = 'adding';
|
||||
|
||||
const user_email = $events_loc.leads.auth_exhibit_kv?.[exhibit_id]?.key || 'shared_passcode';
|
||||
try {
|
||||
await events_func.create_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id,
|
||||
event_badge_id: item.badge.event_badge_id_random,
|
||||
external_person_id: user_email,
|
||||
group: user_email
|
||||
});
|
||||
item.status = 'added';
|
||||
if (on_lead_added) on_lead_added(item.badge);
|
||||
// Brief success display, then fade out
|
||||
setTimeout(() => dismiss_item(item), 1000);
|
||||
} catch {
|
||||
item.status = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function add_all() {
|
||||
const to_add = batch.filter(i => i.status === 'ready' && !i.dismissing);
|
||||
await Promise.all(to_add.map(add_lead));
|
||||
}
|
||||
|
||||
function dismiss_item(item: BatchItem) {
|
||||
item.dismissing = true;
|
||||
// Remove from array after CSS transition completes (300ms)
|
||||
setTimeout(() => {
|
||||
const idx = batch.findIndex(i => i.id === item.id);
|
||||
if (idx >= 0) batch.splice(idx, 1);
|
||||
}, 350);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="multi-scanner flex flex-col items-center gap-4 w-full">
|
||||
|
||||
{#if !is_supported}
|
||||
<!-- Firefox / older browser fallback -->
|
||||
<div class="card p-6 w-full max-w-md space-y-3 preset-tonal-warning text-center border-2 border-warning-500">
|
||||
<Layers size="2.5em" class="mx-auto text-warning-500" />
|
||||
<h3 class="h4 font-bold">Multi-Scan Not Available</h3>
|
||||
<p class="text-sm opacity-70">
|
||||
Multi-scan uses the browser's BarcodeDetector API, which is supported in
|
||||
Chrome, Edge, and Safari 17+. Firefox support is coming soon.
|
||||
</p>
|
||||
<p class="text-sm opacity-70">Use <strong>Rapid</strong> or <strong>Auto</strong> mode in the meantime.</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
|
||||
<!-- Camera viewfinder — landscape 16:9 gives more horizontal coverage for multiple badges -->
|
||||
<div class="w-full max-w-md relative rounded-xl overflow-hidden border-4 border-surface-500/20 shadow-xl bg-black aspect-video">
|
||||
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video
|
||||
bind:this={video_el}
|
||||
class="w-full h-full object-cover"
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
|
||||
<!-- Hint overlay: shown while camera is live, styled like a check-deposit scanner guide -->
|
||||
{#if camera_status === 'live'}
|
||||
<div class="absolute inset-0 pointer-events-none flex flex-col justify-end items-center pb-3 px-4">
|
||||
<span class="bg-black/50 text-white text-xs font-semibold px-3 py-1.5 rounded-full tracking-wide text-center">
|
||||
Align up to 4 badges flat in frame
|
||||
</span>
|
||||
</div>
|
||||
<!-- Corner guides — visual aid for badge alignment -->
|
||||
<div class="absolute inset-4 pointer-events-none">
|
||||
<div class="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-primary-400/70 rounded-tl"></div>
|
||||
<div class="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-primary-400/70 rounded-tr"></div>
|
||||
<div class="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-primary-400/70 rounded-bl"></div>
|
||||
<div class="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-primary-400/70 rounded-br"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Starting overlay -->
|
||||
{#if camera_status === 'starting'}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-black/40">
|
||||
<span class="bg-black/60 text-white text-sm font-semibold px-4 py-2 rounded-full animate-pulse shadow-lg">
|
||||
Starting camera...
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Error overlay -->
|
||||
{#if camera_status === 'error'}
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-black/80 rounded-xl p-6 text-center">
|
||||
<p class="text-white text-sm font-semibold leading-snug drop-shadow">{camera_error}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-white text-surface-950 hover:bg-surface-100 font-bold text-base px-8 py-3 rounded-xl shadow-lg transition-colors cursor-pointer flex items-center gap-2"
|
||||
onclick={retry_camera}
|
||||
>
|
||||
<RefreshCw size="1.2em" />
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Capture button -->
|
||||
{#if camera_status === 'live' || camera_status === 'capturing'}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full max-w-md rounded-xl py-4 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={camera_status === 'capturing'}
|
||||
onclick={capture_batch}
|
||||
>
|
||||
{#if camera_status === 'capturing'}
|
||||
<LoaderCircle class="animate-spin" size="1.3em" />
|
||||
Scanning...
|
||||
{:else}
|
||||
<ScanLine size="1.3em" />
|
||||
Capture Batch
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Badge grid -->
|
||||
{#if batch.length > 0}
|
||||
<div class="w-full grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{#each batch as item (item.id)}
|
||||
<div
|
||||
class="batch-card card p-4 space-y-3 bg-surface-50-900 border border-surface-500/20 shadow min-h-[7rem]"
|
||||
class:dismissing={item.dismissing}
|
||||
>
|
||||
{#if item.status === 'loading'}
|
||||
<!-- Skeleton — fixed height prevents layout bounce as badges load -->
|
||||
<div class="space-y-2">
|
||||
<div class="h-5 w-3/4 bg-surface-200-800 animate-pulse rounded"></div>
|
||||
<div class="h-4 w-1/2 bg-surface-200-800 animate-pulse rounded"></div>
|
||||
</div>
|
||||
<div class="h-9 bg-surface-200-800 animate-pulse rounded-lg"></div>
|
||||
|
||||
{:else if item.status === 'blocked'}
|
||||
<!-- Tracking opt-out — show card so staff can inform the attendee -->
|
||||
<div class="flex items-start gap-2">
|
||||
<ShieldOff size="1.2em" class="text-warning-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || 'Attendee'}</p>
|
||||
<p class="text-xs opacity-60 mt-0.5">Opted out of lead scanning</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg py-2 text-sm font-bold flex items-center justify-center gap-1.5 border border-warning-500/40 text-warning-600 dark:text-warning-400 hover:bg-warning-500/10 transition-colors cursor-pointer"
|
||||
onclick={() => dismiss_item(item)}
|
||||
>
|
||||
<X size="1em" />
|
||||
OK, Dismiss
|
||||
</button>
|
||||
|
||||
{:else if item.status === 'already_added'}
|
||||
<div class="flex items-start gap-2">
|
||||
<CircleCheck size="1.2em" class="text-secondary-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || 'Attendee'}</p>
|
||||
<p class="text-xs opacity-60 mt-0.5">Already captured</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${item.existing_tracking_id}`}
|
||||
class="flex-1 rounded-lg py-2 text-xs font-bold flex items-center justify-center gap-1 border border-surface-500/40 hover:bg-surface-200-800 transition-colors"
|
||||
>
|
||||
<Eye size="0.9em" />
|
||||
View
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg py-2 text-xs font-bold flex items-center justify-center gap-1 border border-surface-500/40 hover:bg-surface-200-800 transition-colors cursor-pointer"
|
||||
onclick={() => dismiss_item(item)}
|
||||
>
|
||||
<X size="0.9em" />
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if item.status === 'ready'}
|
||||
<div>
|
||||
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || 'Badge Found'}</p>
|
||||
<p class="text-xs opacity-60">{item.badge?.affiliations || ''}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-lg py-2.5 text-sm font-bold flex items-center justify-center gap-1.5 bg-primary-500 text-white hover:brightness-110 transition-all cursor-pointer"
|
||||
onclick={() => add_lead(item)}
|
||||
>
|
||||
<UserPlus size="1em" />
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-none rounded-lg px-3 py-2.5 flex items-center justify-center border border-surface-500/40 hover:bg-surface-200-800 transition-colors cursor-pointer opacity-60"
|
||||
title="Skip this badge"
|
||||
onclick={() => dismiss_item(item)}
|
||||
>
|
||||
<X size="1em" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if item.status === 'adding'}
|
||||
<div class="flex items-center gap-2 py-1 opacity-70">
|
||||
<LoaderCircle size="1.2em" class="animate-spin text-primary-500 shrink-0" />
|
||||
<div>
|
||||
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || '...'}</p>
|
||||
<p class="text-xs opacity-60">Adding...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if item.status === 'added'}
|
||||
<div class="flex items-center gap-2 py-1">
|
||||
<CircleCheck size="1.2em" class="text-success-500 shrink-0" />
|
||||
<div>
|
||||
<p class="font-bold text-sm leading-tight text-success-600 dark:text-success-400">{item.badge?.full_name || 'Lead'}</p>
|
||||
<p class="text-xs opacity-60">Lead added!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if item.status === 'error'}
|
||||
<div>
|
||||
<p class="text-sm font-bold text-error-600 dark:text-error-400">Failed to add</p>
|
||||
<p class="text-xs opacity-60">{item.badge?.full_name || 'Unknown'}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg py-2 text-xs font-bold flex items-center justify-center gap-1.5 border border-error-500/40 text-error-600 dark:text-error-400 hover:bg-error-500/10 transition-colors cursor-pointer"
|
||||
onclick={() => dismiss_item(item)}
|
||||
>
|
||||
<X size="1em" />
|
||||
Dismiss
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Add All — only shown when there are ready cards -->
|
||||
{#if ready_count > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-xl py-4 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"
|
||||
onclick={add_all}
|
||||
>
|
||||
<UserPlus size="1.3em" />
|
||||
Add All ({ready_count})
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Smooth fade-and-shrink when a card is dismissed (Add / Skip / OK).
|
||||
Duration matches the 350ms setTimeout in dismiss_item(). */
|
||||
.batch-card {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
.batch-card.dismissing {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -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]);
|
||||
</script>
|
||||
|
||||
<div class="ae-tab-add flex flex-col items-center space-y-4 w-full mx-auto">
|
||||
<div class="ae-tab-add flex flex-col items-center gap-3 w-full mx-auto">
|
||||
|
||||
<!-- Controls Row: input mode toggle + scan qualify toggle -->
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
|
||||
<!-- QR / Search toggle (takes remaining space) -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-filled-secondary font-bold shadow-sm px-4 py-2.5 flex items-center gap-2 flex-1 transition-all"
|
||||
onclick={() => set_mode(mode === 'qr' ? 'search' : 'qr')}
|
||||
>
|
||||
{#if mode === 'qr'}
|
||||
<Search size="1.1em" />
|
||||
<span>Manual Search</span>
|
||||
{:else}
|
||||
<QrCode size="1.1em" />
|
||||
<span>QR Scan</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Rapid / Qualify mode (only meaningful for QR mode) -->
|
||||
<!-- QR / Search toggle -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-filled-secondary font-bold shadow-sm px-4 py-2.5 flex items-center gap-2 w-full transition-all"
|
||||
onclick={() => set_mode(mode === 'qr' ? 'search' : 'qr')}
|
||||
>
|
||||
{#if mode === 'qr'}
|
||||
<div class="flex rounded-lg overflow-hidden border border-surface-500/30 shadow-sm flex-none">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm px-3 py-2.5 rounded-none transition-all"
|
||||
class:preset-filled-primary={scan_qualify === 'rapid'}
|
||||
class:preset-outlined-surface={scan_qualify !== 'rapid'}
|
||||
onclick={() => set_scan_qualify('rapid')}
|
||||
title="Rapid Scan — reset immediately and scan the next person"
|
||||
>
|
||||
<Zap size="1.1em" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm px-3 py-2.5 rounded-none border-l border-surface-500/20 transition-all"
|
||||
class:preset-filled-secondary={scan_qualify === 'qualify'}
|
||||
class:preset-outlined-surface={scan_qualify !== 'qualify'}
|
||||
onclick={() => set_scan_qualify('qualify')}
|
||||
title="Qualify Mode — open lead detail after adding to fill in notes"
|
||||
>
|
||||
<ClipboardList size="1.1em" />
|
||||
</button>
|
||||
</div>
|
||||
<Search size="1.1em" />
|
||||
<span>Switch to Manual Search</span>
|
||||
{:else}
|
||||
<QrCode size="1.1em" />
|
||||
<span>Switch to QR Scan</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Mode hint line -->
|
||||
<!-- Scan mode selector (QR mode only) -->
|
||||
{#if mode === 'qr'}
|
||||
<p class="text-[10px] uppercase font-bold tracking-widest opacity-30 w-full -mt-2">
|
||||
{scan_qualify === 'rapid' ? 'Rapid Scan — scan next after adding' : 'Qualify Mode — open lead detail after adding'}
|
||||
</p>
|
||||
<div class="w-full">
|
||||
|
||||
<!-- Trigger: shows active mode, tapping expands options -->
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-surface-100-900 border border-surface-500/20 shadow-sm transition-colors cursor-pointer"
|
||||
onclick={() => show_mode_opts = !show_mode_opts}
|
||||
title="Change scan mode"
|
||||
>
|
||||
<!-- 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'}
|
||||
>
|
||||
<active_mode.icon size="1em" />
|
||||
</span>
|
||||
|
||||
<!-- Mode name + description -->
|
||||
<div class="flex-1 text-left">
|
||||
<span class="font-bold text-sm">{active_mode.label}</span>
|
||||
<span class="text-xs opacity-50 ml-2">{active_mode.desc}</span>
|
||||
</div>
|
||||
|
||||
<!-- Chevron -->
|
||||
<ChevronDown
|
||||
size="1.1em"
|
||||
class="opacity-40 transition-transform duration-200 {show_mode_opts ? 'rotate-180' : ''}"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- 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">
|
||||
{#each qr_modes as m}
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center gap-1.5 p-3 rounded-lg text-center transition-all cursor-pointer"
|
||||
class:bg-surface-100-900={scan_qualify !== m.value}
|
||||
class:opacity-50={scan_qualify !== m.value}
|
||||
class:bg-surface-50-900={scan_qualify === m.value}
|
||||
class:shadow={scan_qualify === m.value}
|
||||
class:ring-1={scan_qualify === m.value}
|
||||
class:ring-surface-500={scan_qualify === m.value}
|
||||
onclick={() => set_scan_qualify(m.value)}
|
||||
>
|
||||
<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'}
|
||||
>
|
||||
<m.icon size="1.1em" />
|
||||
</span>
|
||||
<span class="font-bold text-sm">{m.label}</span>
|
||||
<span class="text-[10px] opacity-60 leading-tight">{m.desc}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="w-full flex flex-col items-center min-h-100">
|
||||
{#if mode === 'qr'}
|
||||
<Comp_lead_qr_scanner {exhibit_id} {scan_qualify} on_lead_added={handle_lead_added} />
|
||||
{#if scan_qualify === 'multi'}
|
||||
<Comp_lead_qr_scanner_multi {exhibit_id} on_lead_added={handle_lead_added} />
|
||||
{:else}
|
||||
<Comp_lead_qr_scanner {exhibit_id} {scan_qualify} on_lead_added={handle_lead_added} />
|
||||
{/if}
|
||||
{:else}
|
||||
<Comp_lead_manual_search {exhibit_id} on_lead_added={handle_lead_added} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user