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:
Scott Idem
2026-03-20 16:30:38 -04:00
parent 14c2635df4
commit 334c3a21bc
6 changed files with 727 additions and 106 deletions

View File

@@ -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.

View File

@@ -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>

View File

@@ -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.

View File

@@ -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>

View File

@@ -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 14 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>

View File

@@ -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>