feat(leads): V3 API migration, QR Scanner v3, and Exhibitor Leads UI overhaul
- Migrate event_exhibit and event_exhibit_tracking CRUD to V3 API (parent_type/child_type params). - Implement Element_qr_scanner_v3.svelte: A Svelte 5 / Runes component using html5-qrcode with auto-start and unique viewfinder IDs. - Integrate QR Scanner v3 into ae_comp__badge_search.svelte and lead capture. - Refactor Exhibitor Leads UI: - Add 'Rapid Scan' vs 'Qualify Mode' toggles for efficient lead capture. - Upgrade ae_comp__lead_detail_form.svelte to support new question/response schema with backward compatibility. - Implement 'Sign Out of Booth' functionality in exhibit management. - Optimize lead detail layout for mobile readability and high information density. - Fix component prop sync for event_id and exhibit_id. - UI/UX refinements: standardizing icons (SquarePen), cleaning up unused imports, and improving responsive states.
This commit is contained in:
@@ -309,9 +309,9 @@ export async function create_ae_obj__exhibit({
|
||||
}): Promise<ae_EventExhibit | null> {
|
||||
const result = await api.create_nested_obj_v3({
|
||||
api_cfg,
|
||||
for_obj_type: 'event',
|
||||
for_obj_id: event_id,
|
||||
obj_type: 'event_exhibit',
|
||||
parent_type: 'event',
|
||||
parent_id: event_id,
|
||||
child_type: 'event_exhibit',
|
||||
fields: { ...data_kv },
|
||||
log_lvl
|
||||
});
|
||||
@@ -353,10 +353,10 @@ export async function update_ae_obj__exhibit({
|
||||
}): Promise<ae_EventExhibit | null> {
|
||||
const result = await api.update_nested_obj_v3({
|
||||
api_cfg,
|
||||
for_obj_type: 'event',
|
||||
for_obj_id: event_id,
|
||||
obj_type: 'event_exhibit',
|
||||
obj_id: exhibit_id,
|
||||
parent_type: 'event',
|
||||
parent_id: event_id,
|
||||
child_type: 'event_exhibit',
|
||||
child_id: exhibit_id,
|
||||
fields: data_kv,
|
||||
log_lvl
|
||||
});
|
||||
|
||||
@@ -326,9 +326,9 @@ export async function create_ae_obj__exhibit_tracking({
|
||||
}): Promise<ae_EventExhibitTracking | null> {
|
||||
const result = await api.create_nested_obj_v3({
|
||||
api_cfg,
|
||||
for_obj_type: 'event_exhibit',
|
||||
for_obj_id: exhibit_id,
|
||||
obj_type: 'event_exhibit_tracking',
|
||||
parent_type: 'event_exhibit',
|
||||
parent_id: exhibit_id,
|
||||
child_type: 'event_exhibit_tracking',
|
||||
fields: {
|
||||
event_badge_id: event_badge_id,
|
||||
external_person_id,
|
||||
@@ -374,10 +374,10 @@ export async function update_ae_obj__exhibit_tracking({
|
||||
}): Promise<ae_EventExhibitTracking | null> {
|
||||
const result = await api.update_nested_obj_v3({
|
||||
api_cfg,
|
||||
for_obj_type: 'event_exhibit',
|
||||
for_obj_id: exhibit_id,
|
||||
obj_type: 'event_exhibit_tracking',
|
||||
obj_id: exhibit_tracking_id,
|
||||
parent_type: 'event_exhibit',
|
||||
parent_id: exhibit_id,
|
||||
child_type: 'event_exhibit_tracking',
|
||||
child_id: exhibit_tracking_id,
|
||||
fields: data,
|
||||
log_lvl
|
||||
});
|
||||
|
||||
143
src/lib/element_qr_scanner_v3.svelte
Normal file
143
src/lib/element_qr_scanner_v3.svelte
Normal file
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/lib/element_qr_scanner_v3.svelte
|
||||
* QR Scanner v3 — Svelte 5 runes, auto-starts, no manual permission step.
|
||||
*
|
||||
* html5-qrcode's .start() handles camera permission internally.
|
||||
* A unique viewfinder ID is generated per instance so multiple scanners
|
||||
* can coexist on the same page without collision.
|
||||
*
|
||||
* Props:
|
||||
* start_qr_scanner (bindable) — true = scan, false = stop
|
||||
* on_qr_scan_result — callback fired with { detail: { result, entry_method } }
|
||||
* qr_fps — scan frames per second (default 10)
|
||||
* qr_facing_mode — 'environment' (rear) or 'user' (front)
|
||||
*/
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { Html5Qrcode, Html5QrcodeSupportedFormats } from 'html5-qrcode';
|
||||
|
||||
interface Props {
|
||||
start_qr_scanner?: boolean;
|
||||
on_qr_scan_result?: (event: { detail: { result: string; entry_method: string } }) => void;
|
||||
qr_fps?: number;
|
||||
qr_facing_mode?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
start_qr_scanner = $bindable(true),
|
||||
on_qr_scan_result,
|
||||
qr_fps = 10,
|
||||
qr_facing_mode = 'environment'
|
||||
}: Props = $props();
|
||||
|
||||
// Unique DOM ID per instance — prevents conflicts when multiple scanners mount
|
||||
const viewfinder_id = `qr_vf_${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
let scanner: Html5Qrcode | null = null;
|
||||
let status = $state<'idle' | 'starting' | 'scanning' | 'error'>('idle');
|
||||
let error_msg = $state('');
|
||||
|
||||
// React to start_qr_scanner prop changes from the parent
|
||||
$effect(() => {
|
||||
const should_scan = start_qr_scanner;
|
||||
|
||||
untrack(() => {
|
||||
if (should_scan && (status === 'idle' || status === 'error')) {
|
||||
start_scanning();
|
||||
} else if (!should_scan && status === 'scanning') {
|
||||
stop_scanning();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
stop_scanning();
|
||||
});
|
||||
|
||||
async function start_scanning() {
|
||||
if (status === 'starting' || status === 'scanning') return;
|
||||
status = 'starting';
|
||||
error_msg = '';
|
||||
|
||||
try {
|
||||
scanner = new Html5Qrcode(viewfinder_id, {
|
||||
formatsToSupport: [Html5QrcodeSupportedFormats.QR_CODE],
|
||||
verbose: false
|
||||
});
|
||||
|
||||
await scanner.start(
|
||||
{ facingMode: qr_facing_mode },
|
||||
{
|
||||
fps: qr_fps,
|
||||
// Use a percentage of the viewfinder so it scales on any screen size
|
||||
qrbox: (w: number, h: number) => {
|
||||
const side = Math.floor(Math.min(w, h) * 0.82);
|
||||
return { width: side, height: side };
|
||||
}
|
||||
},
|
||||
on_scan_success,
|
||||
on_scan_error
|
||||
);
|
||||
|
||||
status = 'scanning';
|
||||
} catch (e: any) {
|
||||
status = 'error';
|
||||
if (e?.name === 'NotAllowedError') {
|
||||
error_msg = 'Camera access denied. Please allow camera in your browser settings and try again.';
|
||||
} else {
|
||||
error_msg = 'Could not start camera. Please try again.';
|
||||
}
|
||||
console.warn('[QR v3] Scanner start failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function stop_scanning() {
|
||||
if (!scanner) return;
|
||||
const s = scanner;
|
||||
scanner = null;
|
||||
try {
|
||||
await s.stop();
|
||||
await s.clear();
|
||||
} catch {
|
||||
// Ignore cleanup errors — component may be unmounting
|
||||
}
|
||||
status = 'idle';
|
||||
}
|
||||
|
||||
function on_scan_success(decoded_text: string) {
|
||||
// Stop scanning before notifying parent so the camera shuts down cleanly
|
||||
stop_scanning().then(() => {
|
||||
start_qr_scanner = false;
|
||||
});
|
||||
|
||||
if (on_qr_scan_result) {
|
||||
on_qr_scan_result({ detail: { result: decoded_text, entry_method: 'QR' } });
|
||||
}
|
||||
}
|
||||
|
||||
function on_scan_error(_msg: string) {
|
||||
// Called on every frame that doesn't contain a QR code — expected, not an error
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Viewfinder fills whatever container the parent provides -->
|
||||
<div class="qr-scanner-v3 w-full h-full flex flex-col items-center justify-center">
|
||||
<div id={viewfinder_id} class="w-full h-full"></div>
|
||||
|
||||
{#if status === 'starting'}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-surface-900/40 rounded-xl">
|
||||
<p class="text-sm font-semibold opacity-70 animate-pulse">Starting camera...</p>
|
||||
</div>
|
||||
{:else if status === 'error'}
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-surface-900/80 rounded-xl p-6 text-center">
|
||||
<p class="text-sm text-error-400 font-semibold leading-snug">{error_msg}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-filled-primary"
|
||||
onclick={start_scanning}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -10,16 +10,14 @@
|
||||
import {
|
||||
Library,
|
||||
RemoveFormatting,
|
||||
X,
|
||||
QrCode,
|
||||
Search,
|
||||
Check,
|
||||
LoaderCircle
|
||||
} from 'lucide-svelte';
|
||||
|
||||
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
|
||||
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
|
||||
import Element_qr_scanner_v2 from '$lib/element_qr_scanner_v2.svelte';
|
||||
import Element_qr_scanner_v3 from '$lib/element_qr_scanner_v3.svelte';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
|
||||
// ISHLT 2024 badge type codes
|
||||
@@ -53,7 +51,7 @@
|
||||
};
|
||||
}
|
||||
|
||||
function handle_qr_scan_result(event: CustomEvent) {
|
||||
function handle_qr_scan_result(event: { detail: { result: string; entry_method: string } }) {
|
||||
let qr_scan_result = event.detail.result;
|
||||
let obj = ae_util.process_data_string(qr_scan_result);
|
||||
|
||||
@@ -202,9 +200,9 @@
|
||||
<div
|
||||
class="w-full max-w-2xl mx-auto p-4 bg-surface-100-900 rounded-lg shadow-lg"
|
||||
>
|
||||
<Element_qr_scanner_v2
|
||||
<Element_qr_scanner_v3
|
||||
bind:start_qr_scanner={$events_sess.badges.qr_scan_start}
|
||||
on:qr_scan_result={handle_qr_scan_result}
|
||||
on_qr_scan_result={handle_qr_scan_result}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -240,24 +238,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
$events_loc.badges.use_id_li = !$events_loc.badges.use_id_li;
|
||||
handle_search_trigger();
|
||||
}}
|
||||
class="btn btn-sm preset-tonal-secondary border border-secondary-500"
|
||||
title="Toggle using the ID list or not."
|
||||
>
|
||||
{#if $events_loc.badges.use_id_li}
|
||||
<Check size="1.2em" class="text-green-600 mr-1" />
|
||||
{:else}
|
||||
<X size="1.2em" class="text-red-600 mr-1" />
|
||||
{/if}
|
||||
Use ID List
|
||||
</button>
|
||||
|
||||
{#if $ae_loc.edit_mode}
|
||||
{#if $ae_loc.edit_mode}
|
||||
<label
|
||||
class="flex items-center gap-1 cursor-pointer bg-surface-200-800 px-2 py-1 rounded-token text-xs font-semibold"
|
||||
>
|
||||
|
||||
@@ -13,15 +13,11 @@
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import {
|
||||
LoaderCircle,
|
||||
UserPlus,
|
||||
Download,
|
||||
Settings,
|
||||
Plus,
|
||||
List as ListIcon,
|
||||
LogIn,
|
||||
LayoutGrid,
|
||||
Search,
|
||||
LogOut
|
||||
LayoutGrid
|
||||
} from 'lucide-svelte';
|
||||
import Comp_exhibit_tracking_search from './ae_comp__exhibit_tracking_search.svelte';
|
||||
import Comp_exhibit_tracking_obj_li from './ae_comp__exhibit_tracking_obj_li.svelte';
|
||||
@@ -383,15 +379,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handle_signout() {
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
if (!exhibit_id) return;
|
||||
if (confirm('Sign out from this booth?')) {
|
||||
delete $events_loc.leads.auth_exhibit_kv[exhibit_id];
|
||||
$events_sess.leads.entered_passcode = null;
|
||||
set_active_tab('start');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section
|
||||
@@ -449,15 +436,6 @@
|
||||
<Settings size="1.25em" />
|
||||
</button>
|
||||
|
||||
<!-- Sign Out -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-ghost-error px-2 sm:px-3"
|
||||
onclick={handle_signout}
|
||||
title="Sign Out"
|
||||
>
|
||||
<LogOut size="1.25em" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -6,40 +6,59 @@
|
||||
import { untrack } from 'svelte';
|
||||
import { ae_api } from '$lib/stores/ae_stores';
|
||||
import { events_func } from '$lib/ae_events_functions';
|
||||
import { Plus, Trash2, Save, LoaderCircle, MessageSquare, List, Type, CheckSquare } from 'lucide-svelte';
|
||||
import { Plus, Trash2, Save, LoaderCircle, MessageSquare, List } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
event_id: string;
|
||||
custom_questions_json?: string;
|
||||
}
|
||||
|
||||
let { exhibit_id, custom_questions_json = '[]' }: Props = $props();
|
||||
let { exhibit_id, event_id, custom_questions_json = '[]' }: Props = $props();
|
||||
|
||||
let questions: any[] = $state([]);
|
||||
let is_saving = $state(false);
|
||||
// Track the JSON as it was last saved so we can detect unsaved changes
|
||||
let saved_json = $state('[]');
|
||||
|
||||
$effect(() => {
|
||||
const incoming = custom_questions_json; // reactive dependency
|
||||
try {
|
||||
const parsed = JSON.parse(custom_questions_json || '[]');
|
||||
const parsed = JSON.parse(incoming || '[]');
|
||||
untrack(() => {
|
||||
questions = Array.isArray(parsed) ? parsed : [];
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
// Incoming prop has real content — load it (initial load or external update)
|
||||
questions = parsed;
|
||||
saved_json = JSON.stringify(parsed);
|
||||
} else if (questions.length === 0) {
|
||||
// Both empty — initialize state cleanly
|
||||
saved_json = '[]';
|
||||
}
|
||||
// If parsed is empty but we already have questions: the API response
|
||||
// stripped leads_custom_questions_json from its return object and
|
||||
// overwrote Dexie with null. Keep our in-memory questions intact.
|
||||
});
|
||||
} catch (e) {
|
||||
untrack(() => questions = []);
|
||||
untrack(() => { if (questions.length === 0) { questions = []; saved_json = '[]'; } });
|
||||
}
|
||||
});
|
||||
|
||||
// True whenever the current questions differ from the last saved state
|
||||
let is_dirty = $derived(JSON.stringify(questions) !== saved_json);
|
||||
|
||||
async function save_questions() {
|
||||
if (!exhibit_id) return;
|
||||
is_saving = true;
|
||||
try {
|
||||
await events_func.update_ae_obj__exhibit({
|
||||
api_cfg: $ae_api,
|
||||
event_id: event_id,
|
||||
exhibit_id: exhibit_id,
|
||||
data_kv: {
|
||||
leads_custom_questions_json: JSON.stringify(questions)
|
||||
}
|
||||
});
|
||||
saved_json = JSON.stringify(questions);
|
||||
} finally {
|
||||
is_saving = false;
|
||||
}
|
||||
@@ -47,16 +66,30 @@
|
||||
|
||||
function add_question() {
|
||||
questions.push({
|
||||
id: Math.random().toString(36).substring(2, 9),
|
||||
label: '',
|
||||
// code: machine key used as the responses_json property name
|
||||
// question: human-readable label shown to the exhibitor/scanner
|
||||
// option_li: array of choices; first element is always '' (blank/no-selection default)
|
||||
code: '',
|
||||
question: '',
|
||||
type: 'text',
|
||||
options: ''
|
||||
option_li: ['']
|
||||
});
|
||||
}
|
||||
|
||||
function remove_question(index: number) {
|
||||
questions.splice(index, 1);
|
||||
}
|
||||
|
||||
// Helpers for option_li ↔ comma-string conversion in the UI
|
||||
function get_options_str(q: any): string {
|
||||
const li: string[] = Array.isArray(q.option_li) ? q.option_li : [];
|
||||
return li.filter((o: string) => o !== '').join(', ');
|
||||
}
|
||||
|
||||
function set_options_str(q: any, val: string) {
|
||||
// Always prepend empty string so the select has a blank default option
|
||||
q.option_li = ['', ...val.split(',').map((s: string) => s.trim()).filter(Boolean)];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="custom-questions-editor space-y-4">
|
||||
@@ -67,42 +100,59 @@
|
||||
|
||||
<div class="space-y-3">
|
||||
{#each questions as q, i}
|
||||
<div class="card p-4 variant-soft border border-surface-500/10 space-y-4 relative group animate-in fade-in slide-in-from-right-2">
|
||||
<button
|
||||
class="absolute top-2 right-2 p-2 text-error-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onclick={() => remove_question(i)}
|
||||
>
|
||||
<Trash2 size="1.2em" />
|
||||
</button>
|
||||
<div class="card p-4 variant-soft border border-surface-500/10 space-y-3 animate-in fade-in slide-in-from-right-2">
|
||||
<!-- Question header row: number + delete (always visible for mobile) -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] uppercase font-black opacity-30 tracking-widest">Question {i + 1}</span>
|
||||
<button
|
||||
class="btn btn-sm variant-ghost-error px-2 py-1"
|
||||
onclick={() => remove_question(i)}
|
||||
title="Remove question"
|
||||
>
|
||||
<Trash2 size="1em" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<!-- Label -->
|
||||
<!-- Question / display label -->
|
||||
<div class="space-y-1">
|
||||
<label class="text-[10px] uppercase font-bold opacity-40">Question / Label</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<MessageSquare size="1em" class="opacity-30" />
|
||||
<input type="text" bind:value={q.label} placeholder="e.g. Purchasing Authority?" class="bg-transparent border-b border-surface-500/20 outline-none flex-1 text-sm font-bold" />
|
||||
<MessageSquare size="1em" class="opacity-30 flex-none" />
|
||||
<input type="text" bind:value={q.question} placeholder="e.g. Purchasing Authority?" class="bg-transparent border-b border-surface-500/20 outline-none flex-1 text-sm font-bold" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type -->
|
||||
<!-- Code / machine key -->
|
||||
<div class="space-y-1">
|
||||
<label class="text-[10px] uppercase font-bold opacity-40">Response Type</label>
|
||||
<select bind:value={q.type} class="select variant-filled-surface text-xs p-1 rounded">
|
||||
<option value="text">Short Text</option>
|
||||
<option value="textarea">Long Text</option>
|
||||
<option value="toggle">Yes / No (Toggle)</option>
|
||||
<option value="select">Multiple Choice (Select)</option>
|
||||
</select>
|
||||
<label class="text-[10px] uppercase font-bold opacity-40">Field Code <span class="normal-case font-normal opacity-70">(key in export)</span></label>
|
||||
<input type="text" bind:value={q.code} placeholder="e.g. purchasing_authority" class="bg-transparent border-b border-surface-500/20 outline-none w-full text-xs font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if q.type === 'select'}
|
||||
<!-- Response Type -->
|
||||
<div class="space-y-1">
|
||||
<label class="text-[10px] uppercase font-bold opacity-40">Response Type</label>
|
||||
<select bind:value={q.type} class="select variant-filled-surface text-xs p-1 rounded w-full">
|
||||
<option value="text">Short Text</option>
|
||||
<option value="textarea">Long Text</option>
|
||||
<option value="toggle">Yes / No (Toggle)</option>
|
||||
<option value="option">Multiple Choice (Select)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if q.type === 'option'}
|
||||
<div class="space-y-1 pt-2 border-t border-surface-500/10">
|
||||
<label class="text-[10px] uppercase font-bold opacity-40">Options (Comma separated)</label>
|
||||
<label class="text-[10px] uppercase font-bold opacity-40">Options (comma-separated)</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<List size="1em" class="opacity-30" />
|
||||
<input type="text" bind:value={q.options} placeholder="Hot, Warm, Cold" class="bg-transparent border-b border-surface-500/20 outline-none flex-1 text-xs" />
|
||||
<List size="1em" class="opacity-30 flex-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={get_options_str(q)}
|
||||
oninput={(e) => set_options_str(q, (e.target as HTMLInputElement).value)}
|
||||
placeholder="Hot, Warm, Cold"
|
||||
class="bg-transparent border-b border-surface-500/20 outline-none flex-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -112,22 +162,33 @@
|
||||
{#if questions.length === 0}
|
||||
<div class="p-8 text-center border-2 border-dashed border-surface-500/20 rounded-xl opacity-30">
|
||||
<Plus size="2em" class="mx-auto mb-2" />
|
||||
<p class="text-sm italic">No custom questions defined.</p>
|
||||
<p class="text-sm italic">No custom questions defined yet.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Unsaved changes warning -->
|
||||
{#if is_dirty}
|
||||
<p class="text-xs text-warning-500 font-bold text-center animate-pulse">Unsaved changes</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
<button class="btn btn-sm variant-filled-secondary flex-1" onclick={add_question}>
|
||||
<Plus size="1.2em" class="mr-2" /> Add Question
|
||||
</button>
|
||||
<button class="btn btn-sm variant-filled-primary flex-1" onclick={save_questions} disabled={is_saving}>
|
||||
<button
|
||||
class="btn btn-sm flex-1 transition-all"
|
||||
class:variant-filled-primary={is_dirty}
|
||||
class:variant-ghost-surface={!is_dirty}
|
||||
onclick={save_questions}
|
||||
disabled={is_saving || !is_dirty}
|
||||
>
|
||||
{#if is_saving}
|
||||
<LoaderCircle size="1.2em" class="animate-spin mr-2" />
|
||||
{:else}
|
||||
<Save size="1.2em" class="mr-2" />
|
||||
{/if}
|
||||
Save Questions
|
||||
{is_dirty ? 'Save Questions' : 'Saved'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
event_id: string;
|
||||
license_li_json?: string; // Raw JSON string from DB
|
||||
license_max?: number;
|
||||
}
|
||||
|
||||
let { exhibit_id, license_li_json = '[]', license_max = 0 }: Props = $props();
|
||||
let { exhibit_id, event_id, license_li_json = '[]', license_max = 0 }: Props = $props();
|
||||
|
||||
// Local state for the parsed list
|
||||
let local_license_li: any[] = $state([]);
|
||||
@@ -55,6 +56,7 @@
|
||||
const json_str = JSON.stringify(local_license_li);
|
||||
await events_func.update_ae_obj__exhibit({
|
||||
api_cfg: $ae_api,
|
||||
event_id: event_id,
|
||||
exhibit_id: exhibit_id,
|
||||
data_kv: {
|
||||
license_li_json: json_str
|
||||
|
||||
@@ -17,10 +17,47 @@
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import { page } from '$app/state';
|
||||
|
||||
// Helper to format date
|
||||
function format_date(date_str: string) {
|
||||
// Full ISO datetime for tooltip (hover title)
|
||||
function format_date_full(date_str: string) {
|
||||
if (!date_str) return '';
|
||||
return new Date(date_str).toLocaleString();
|
||||
return new Date(date_str).toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Fuzzy relative time — "~10 min ago", "~1.5 hrs ago", "~2 days ago"
|
||||
// Exact for first few minutes, increasingly coarse after that.
|
||||
function fuzzy_time_ago(date_str: string) {
|
||||
if (!date_str) return '';
|
||||
const diff_ms = Date.now() - new Date(date_str).getTime();
|
||||
if (diff_ms < 0) return 'just now';
|
||||
|
||||
const min = diff_ms / 60000;
|
||||
const hr = diff_ms / 3600000;
|
||||
const days = Math.floor(hr / 24);
|
||||
|
||||
if (min < 0.75) return 'just now';
|
||||
if (min < 2) return '~1 min ago';
|
||||
if (min < 7) return `~${Math.round(min)} min ago`; // e.g. "~3 min ago"
|
||||
if (min < 20) return `~${Math.round(min / 5) * 5} min ago`; // rounds to 5 min
|
||||
if (min < 55) return `~${Math.round(min / 15) * 15} min ago`; // rounds to 15 min
|
||||
if (hr < 1.5) return '~1 hr ago';
|
||||
if (hr < 23.5) {
|
||||
const r = Math.round(hr * 2) / 2; // rounds to nearest 0.5 hr
|
||||
return `~${r} hrs ago`;
|
||||
}
|
||||
if (days < 7) {
|
||||
const rem_hr = Math.round(hr - days * 24);
|
||||
const day_label = `~${days} day${days > 1 ? 's' : ''}`;
|
||||
return rem_hr > 1 ? `${day_label} ${rem_hr} hrs ago` : `${day_label} ago`;
|
||||
}
|
||||
const weeks = Math.round(days / 7);
|
||||
if (days < 28) { return `~${weeks} week${weeks > 1 ? 's' : ''} ago`; }
|
||||
const months = Math.round(days / 30);
|
||||
if (days < 365) { return `~${months} month${months > 1 ? 's' : ''} ago`; }
|
||||
const years = Math.round(days / 365);
|
||||
return `~${years} year${years > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -75,9 +112,12 @@
|
||||
{event_tracking_obj.event_badge_affiliations}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-1">
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
title={format_date_full(event_tracking_obj.created_on)}
|
||||
>
|
||||
<Clock size="1em" />
|
||||
{format_date(event_tracking_obj.created_on)}
|
||||
{fuzzy_time_ago(event_tracking_obj.created_on)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,11 +41,16 @@
|
||||
let search_query = $state('');
|
||||
let results: ae_EventBadge[] = $state([]);
|
||||
let searching = $state(false);
|
||||
let adding_id = $state('');
|
||||
let adding_id = $state(''); // badge_id currently being added (shows spinner)
|
||||
let add_error_id = $state(''); // badge_id that failed to add (shows error)
|
||||
// Track the most recently added badge_id → tracking_id so we can show a View link
|
||||
let last_added_badge_id = $state('');
|
||||
let last_added_tracking_id = $state('');
|
||||
|
||||
async function handle_search() {
|
||||
if (!search_query.trim()) return;
|
||||
searching = true;
|
||||
add_error_id = '';
|
||||
try {
|
||||
const search_results = await events_func.search__event_badge({
|
||||
api_cfg: $ae_api,
|
||||
@@ -62,25 +67,40 @@
|
||||
}
|
||||
|
||||
async function add_as_lead(badge: ae_EventBadge) {
|
||||
if (!badge.event_badge_id_random) return;
|
||||
adding_id = badge.event_badge_id_random;
|
||||
|
||||
// Use id or id_random — whichever is populated from search results
|
||||
const badge_id = badge.event_badge_id_random || badge.event_badge_id;
|
||||
if (!badge_id) {
|
||||
console.warn('[add_as_lead] badge missing event_badge_id_random and event_badge_id', badge);
|
||||
return;
|
||||
}
|
||||
adding_id = badge_id;
|
||||
add_error_id = '';
|
||||
|
||||
// Use the actual signed-in licensed user's email (stored in auth_exhibit_kv)
|
||||
const user_email = $events_loc.leads.auth_exhibit_kv?.[exhibit_id]?.key || 'shared_passcode';
|
||||
|
||||
try {
|
||||
const result = await events_func.create_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: exhibit_id,
|
||||
event_badge_id: badge.event_badge_id_random,
|
||||
external_person_id: user_email,
|
||||
group: user_email
|
||||
});
|
||||
|
||||
if (result && on_lead_added) { on_lead_added(badge);
|
||||
try {
|
||||
const result = await events_func.create_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: exhibit_id,
|
||||
event_badge_id: badge_id,
|
||||
external_person_id: user_email,
|
||||
group: user_email
|
||||
});
|
||||
|
||||
if (result) {
|
||||
// Surface a View Details link next to this result row
|
||||
last_added_badge_id = badge_id;
|
||||
last_added_tracking_id = result.event_exhibit_tracking_id_random || String(result.event_exhibit_tracking_id || '');
|
||||
if (on_lead_added) on_lead_added(badge);
|
||||
} else {
|
||||
// API returned null/false — surface a visible error on this row
|
||||
add_error_id = badge_id;
|
||||
console.warn('[add_as_lead] API returned null for badge_id', badge_id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to add lead', e);
|
||||
add_error_id = badge_id;
|
||||
console.error('[add_as_lead] Failed to add lead', e);
|
||||
} finally {
|
||||
adding_id = '';
|
||||
}
|
||||
@@ -120,28 +140,36 @@
|
||||
{#if results.length > 0}
|
||||
<div class="results-list space-y-2 max-h-[50vh] overflow-y-auto pr-2">
|
||||
{#each results as badge}
|
||||
{@const badge_id = badge.event_badge_id_random || badge.event_badge_id}
|
||||
{@const existing_id = $existing_leads_map?.get(badge_id) ?? (last_added_badge_id === badge_id ? last_added_tracking_id : '')}
|
||||
<div class="card p-3 flex justify-between items-center variant-soft shadow-sm">
|
||||
<div>
|
||||
<div class="font-bold">{badge.full_name}</div>
|
||||
<div class="text-xs opacity-70">{badge.affiliations || badge.email || ''}</div>
|
||||
</div>
|
||||
|
||||
{#if $existing_leads_map?.has(badge.event_badge_id_random)}
|
||||
<a
|
||||
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${$existing_leads_map.get(badge.event_badge_id_random)}`}
|
||||
{#if existing_id}
|
||||
<a
|
||||
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${existing_id}`}
|
||||
class="btn btn-sm variant-filled-secondary"
|
||||
>
|
||||
<Eye size="1em" class="mr-1" />
|
||||
View
|
||||
</a>
|
||||
{:else if add_error_id === badge_id}
|
||||
<span class="text-xs text-error-500 font-bold">Add failed — retry?
|
||||
<button type="button" class="btn btn-sm variant-ghost-error ml-1" onclick={() => add_as_lead(badge)}>
|
||||
Retry
|
||||
</button>
|
||||
</span>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-filled-success"
|
||||
disabled={adding_id === badge.event_badge_id_random}
|
||||
disabled={!!adding_id && adding_id === badge_id}
|
||||
onclick={() => add_as_lead(badge)}
|
||||
>
|
||||
{#if adding_id === badge.event_badge_id_random}
|
||||
{#if adding_id === badge_id}
|
||||
<LoaderCircle class="animate-spin" size="1em" />
|
||||
{:else}
|
||||
<UserPlus size="1em" class="mr-1" />
|
||||
|
||||
@@ -2,24 +2,30 @@
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte
|
||||
* Badge QR Scanner for adding leads.
|
||||
*
|
||||
* scan_qualify modes (controlled by parent ae_tab__add):
|
||||
* - 'rapid': after add → auto-reset scanner (scan next person fast)
|
||||
* - 'qualify': after add → navigate to lead detail (fill notes/qualifiers)
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
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_functions';
|
||||
import Element_qr_scanner_v2 from '$lib/element_qr_scanner_v2.svelte';
|
||||
import Element_qr_scanner_v3 from '$lib/element_qr_scanner_v3.svelte';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import { LoaderCircle, UserPlus, CheckCircle, CircleAlert, Eye } from 'lucide-svelte';
|
||||
import type { ae_EventBadge } from '$lib/types/ae_types';
|
||||
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
scan_qualify?: 'rapid' | 'qualify';
|
||||
on_lead_added?: (badge: ae_EventBadge) => void;
|
||||
}
|
||||
|
||||
let { exhibit_id, on_lead_added }: Props = $props();
|
||||
let { exhibit_id, scan_qualify = 'rapid', on_lead_added }: Props = $props();
|
||||
|
||||
// Track existing leads to prevent duplicates
|
||||
let existing_leads_map = $derived(
|
||||
@@ -42,9 +48,10 @@
|
||||
let scanning_status = $state('idle'); // idle, scanning, found, adding, success, error, already_added
|
||||
let found_badge: ae_EventBadge | null = $state(null);
|
||||
let existing_tracking_id = $state('');
|
||||
let new_tracking_id = $state(''); // ID of the lead just created — used for "View Details" link
|
||||
let error_msg = $state('');
|
||||
|
||||
async function handle_qr_scan_result(event: CustomEvent) {
|
||||
async function handle_qr_scan_result(event: { detail: { result: string; entry_method: string } }) {
|
||||
const qr_result = event.detail.result;
|
||||
const obj = ae_util.process_data_string(qr_result);
|
||||
|
||||
@@ -93,11 +100,18 @@
|
||||
});
|
||||
|
||||
if (result) {
|
||||
// Capture the new tracking ID so we can link to it
|
||||
new_tracking_id = result.event_exhibit_tracking_id_random || String(result.event_exhibit_tracking_id || '');
|
||||
scanning_status = 'success';
|
||||
if (on_lead_added) on_lead_added(found_badge);
|
||||
|
||||
// Auto-reset after 2 seconds to scan next
|
||||
setTimeout(reset_scanner, 2000);
|
||||
if (scan_qualify === 'qualify' && new_tracking_id) {
|
||||
// Qualify mode: navigate directly to lead detail to fill in notes/qualifiers
|
||||
goto(`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`);
|
||||
} else {
|
||||
// Rapid mode: auto-reset after 2 seconds to scan the next person
|
||||
setTimeout(reset_scanner, 2000);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
scanning_status = 'error';
|
||||
@@ -108,17 +122,18 @@
|
||||
function reset_scanner() {
|
||||
scanning_status = 'idle';
|
||||
found_badge = null;
|
||||
new_tracking_id = '';
|
||||
error_msg = '';
|
||||
start_qr_scanner = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="lead-qr-scanner flex flex-col items-center space-y-4 w-full min-h-[400px] justify-center">
|
||||
<div class="lead-qr-scanner flex flex-col items-center space-y-4 w-full min-h-100 justify-center">
|
||||
{#if scanning_status === 'idle' || scanning_status === 'scanning'}
|
||||
<div class="w-full max-w-sm mx-auto aspect-square overflow-hidden rounded-xl border-4 border-surface-500/20 shadow-xl relative bg-surface-900/10">
|
||||
<Element_qr_scanner_v2
|
||||
<Element_qr_scanner_v3
|
||||
bind:start_qr_scanner
|
||||
on:qr_scan_result={handle_qr_scan_result}
|
||||
on_qr_scan_result={handle_qr_scan_result}
|
||||
/>
|
||||
<div class="absolute inset-0 pointer-events-none border-2 border-primary-500/50 m-8 sm:m-12 rounded-lg animate-pulse"></div>
|
||||
</div>
|
||||
@@ -189,7 +204,16 @@
|
||||
<h3 class="h4 font-bold">Lead Added!</h3>
|
||||
<p class="text-xl font-bold">{found_badge?.full_name}</p>
|
||||
</div>
|
||||
<p class="text-sm opacity-50">Resetting scanner...</p>
|
||||
<!-- 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 variant-ghost-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'}
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte
|
||||
* Tab 2: Add - Search / QR Scan Layout.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
import { QrCode, Search } from 'lucide-svelte';
|
||||
import { QrCode, Search, Zap, ClipboardList } from 'lucide-svelte';
|
||||
import Comp_lead_qr_scanner from './ae_comp__lead_qr_scanner.svelte';
|
||||
import Comp_lead_manual_search from './ae_comp__lead_manual_search.svelte';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
@@ -14,7 +20,7 @@
|
||||
|
||||
let { exhibit_id }: Props = $props();
|
||||
|
||||
// Use store for persistence (Stickiness)
|
||||
// QR vs Manual Search (persisted per exhibit)
|
||||
let mode = $derived($events_loc.leads.tab_add_mode?.[exhibit_id] ?? 'qr');
|
||||
|
||||
function set_mode(new_mode: string) {
|
||||
@@ -22,35 +28,77 @@
|
||||
$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');
|
||||
|
||||
function set_scan_qualify(new_mode: 'rapid' | 'qualify') {
|
||||
if (!$events_loc.leads.tab_scan_qualify) $events_loc.leads.tab_scan_qualify = {};
|
||||
$events_loc.leads.tab_scan_qualify[exhibit_id] = new_mode;
|
||||
}
|
||||
|
||||
function handle_lead_added(badge: any) {
|
||||
console.log('Lead successfully added:', badge.full_name);
|
||||
// We could trigger a global list refresh here if needed
|
||||
$events_loc.leads.tracking__search_version++;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ae-tab-add flex flex-col items-center space-y-6 w-full mx-auto">
|
||||
<!-- Mode Toggle - Combined Button -->
|
||||
<div class="flex justify-center w-full">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm variant-filled-secondary font-bold shadow-md px-6 py-3 flex items-center gap-2 transition-all duration-200"
|
||||
<div class="ae-tab-add flex flex-col items-center space-y-4 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 variant-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.2em" />
|
||||
<span>Switch to Manual Search</span>
|
||||
<Search size="1.1em" />
|
||||
<span>Manual Search</span>
|
||||
{:else}
|
||||
<QrCode size="1.2em" />
|
||||
<span>Switch to QR Scan</span>
|
||||
<QrCode size="1.1em" />
|
||||
<span>QR Scan</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Rapid / Qualify mode (only meaningful for QR mode) -->
|
||||
{#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:variant-ghost-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:variant-ghost-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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content Area - Stable Width -->
|
||||
<div class="w-full flex flex-col items-center min-h-[400px]">
|
||||
<!-- Mode hint line -->
|
||||
{#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>
|
||||
{/if}
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="w-full flex flex-col items-center min-h-100">
|
||||
{#if mode === 'qr'}
|
||||
<Comp_lead_qr_scanner {exhibit_id} on_lead_added={handle_lead_added} />
|
||||
<Comp_lead_qr_scanner {exhibit_id} {scan_qualify} on_lead_added={handle_lead_added} />
|
||||
{:else}
|
||||
<Comp_lead_manual_search {exhibit_id} on_lead_added={handle_lead_added} />
|
||||
{/if}
|
||||
|
||||
@@ -13,17 +13,18 @@
|
||||
import Comp_exhibit_license_list from './ae_comp__exhibit_license_list.svelte';
|
||||
import Comp_exhibit_custom_questions from './ae_comp__exhibit_custom_questions.svelte';
|
||||
import Comp_exhibit_payment from './ae_comp__exhibit_payment.svelte';
|
||||
import {
|
||||
Store,
|
||||
Settings,
|
||||
Lock,
|
||||
Info,
|
||||
MessageSquare,
|
||||
import {
|
||||
Store,
|
||||
Settings,
|
||||
Lock,
|
||||
Info,
|
||||
MessageSquare,
|
||||
CreditCard,
|
||||
Key,
|
||||
Users,
|
||||
ChevronRight,
|
||||
ChevronDown
|
||||
ChevronDown,
|
||||
LogOut
|
||||
} from 'lucide-svelte';
|
||||
|
||||
const exhibit_id = $derived(page.params.exhibit_id ?? '');
|
||||
@@ -40,6 +41,16 @@
|
||||
let show_license_mgmt = $state(false);
|
||||
let show_custom_questions = $state(false);
|
||||
let show_billing = $state(false);
|
||||
|
||||
function handle_signout() {
|
||||
if (confirm('Sign out from this booth?')) {
|
||||
delete $events_loc.leads.auth_exhibit_kv[exhibit_id];
|
||||
$events_sess.leads.entered_passcode = null;
|
||||
// Navigate to start tab
|
||||
if (!$events_loc.leads.tab) $events_loc.leads.tab = {};
|
||||
$events_loc.leads.tab[exhibit_id] = 'start';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ae-tab-manage w-full space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-300 pb-20">
|
||||
@@ -224,6 +235,16 @@
|
||||
<p class="text-[9px] opacity-40 mt-2 italic">Official floor plan booth number.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sign Out -->
|
||||
{#if !$ae_loc.manager_access}
|
||||
<button
|
||||
class="btn variant-ghost-error w-full mt-2"
|
||||
onclick={handle_signout}
|
||||
>
|
||||
<LogOut size="1.2em" class="mr-2" /> Sign Out of Booth
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Section: Lead Settings -->
|
||||
@@ -257,8 +278,9 @@
|
||||
|
||||
{#if show_license_mgmt}
|
||||
<div class="p-4 bg-surface-500/5 border-t border-surface-500/10 animate-in fade-in slide-in-from-top-2">
|
||||
<Comp_exhibit_license_list
|
||||
{exhibit_id}
|
||||
<Comp_exhibit_license_list
|
||||
{exhibit_id}
|
||||
event_id={page.params.event_id ?? ''}
|
||||
license_li_json={$lq__exhibit_obj?.license_li_json ?? '[]'}
|
||||
license_max={$lq__exhibit_obj?.license_max}
|
||||
/>
|
||||
@@ -289,8 +311,9 @@
|
||||
|
||||
{#if show_custom_questions}
|
||||
<div class="p-4 bg-surface-500/5 border-t border-surface-500/10 animate-in fade-in slide-in-from-top-2">
|
||||
<Comp_exhibit_custom_questions
|
||||
{exhibit_id}
|
||||
<Comp_exhibit_custom_questions
|
||||
{exhibit_id}
|
||||
event_id={page.params.event_id ?? ''}
|
||||
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ?? '[]'}
|
||||
/>
|
||||
</div>
|
||||
@@ -306,8 +329,8 @@
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-success-500/10 p-2 rounded-lg text-success-500"><CreditCard size="1.2em" /></div>
|
||||
<div class="text-left">
|
||||
<div class="font-bold text-sm">Billing & Upgrades</div>
|
||||
<div class="text-xs opacity-50">Manage subscription and extra devices</div>
|
||||
<div class="font-bold text-sm">Licenses & Billing</div>
|
||||
<div class="text-xs opacity-50">Review licenses and manage payment</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if show_billing}
|
||||
|
||||
@@ -10,12 +10,11 @@
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import Element_ae_crud_v2 from '$lib/elements/element_ae_crud_v2.svelte';
|
||||
import Comp_lead_detail_form from './ae_comp__lead_detail_form.svelte';
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
MapPin,
|
||||
Clock,
|
||||
FileText,
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
MapPin,
|
||||
FileText,
|
||||
ChevronLeft,
|
||||
Store,
|
||||
Briefcase,
|
||||
@@ -24,7 +23,7 @@
|
||||
Star,
|
||||
LoaderCircle,
|
||||
ListTodo,
|
||||
Edit,
|
||||
SquarePen,
|
||||
Eye
|
||||
} from 'lucide-svelte';
|
||||
|
||||
@@ -62,8 +61,8 @@
|
||||
<!-- Local Header -->
|
||||
<header class="w-full bg-surface-100-900 border-b border-surface-500/20 px-4 py-3 sticky top-0 z-10 flex items-center justify-between gap-4 shadow-sm">
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
href={`/events/${page.params.event_id}/leads/exhibit/${$lq__lead_obj?.event_exhibit_id}`}
|
||||
<a
|
||||
href={`/events/${page.params.event_id}/leads/exhibit/${page.params.exhibit_id}`}
|
||||
class="btn btn-sm variant-ghost-surface"
|
||||
>
|
||||
<ChevronLeft size="1.2em" />
|
||||
@@ -83,7 +82,7 @@
|
||||
{#if is_edit_mode}
|
||||
<Eye size="1.2em" class="mr-1" /> View
|
||||
{:else}
|
||||
<Edit size="1.2em" class="mr-1" /> Edit
|
||||
<SquarePen size="1.2em" class="mr-1" /> Edit
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -110,40 +109,36 @@
|
||||
<!-- Left: Profile Info -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<!-- Attendee Core Identity -->
|
||||
<div class="card p-6 variant-soft shadow-lg border-l-4 border-primary-500">
|
||||
<div class="flex flex-col sm:flex-row gap-6 items-center sm:items-start">
|
||||
<div class="bg-primary-500/10 p-5 rounded-full flex-none">
|
||||
<User size="3.5em" class="text-primary-500" />
|
||||
</div>
|
||||
<div class="text-center sm:text-left space-y-1">
|
||||
<h2 class="text-3xl font-black leading-tight">
|
||||
{$lq__lead_obj.event_badge_full_name || $lq__lead_obj.event_badge_full_name_override || 'Unknown Attendee'}
|
||||
</h2>
|
||||
<p class="text-xl opacity-70 flex items-center justify-center sm:justify-start gap-2">
|
||||
<Briefcase size="0.9em" />
|
||||
{$lq__lead_obj.event_badge_professional_title || $lq__lead_obj.event_badge_professional_title_override || 'Professional Title Not Set'}
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-primary-500 flex items-center justify-center sm:justify-start gap-2">
|
||||
<MapPin size="0.9em" />
|
||||
{$lq__lead_obj.event_badge_affiliations || $lq__lead_obj.event_badge_affiliations_override || 'Organization Not Set'}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card p-4 variant-soft shadow-lg border-l-4 border-primary-500 space-y-2">
|
||||
<!-- Name row: small inline icon -->
|
||||
<div class="flex items-center gap-2">
|
||||
<User size="1.4em" class="text-primary-500 flex-none" />
|
||||
<h2 class="text-2xl font-black leading-tight">
|
||||
{@html $lq__lead_obj.event_badge_full_name || $lq__lead_obj.event_badge_full_name_override || 'Unknown Attendee'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8 pt-8 border-t border-surface-500/10">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-surface-200-800 p-2 rounded-lg"><Mail size="1.2em" class="opacity-70" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest">Email Address</div>
|
||||
<div class="font-mono truncate">{$lq__lead_obj.event_badge_email || 'N/A'}</div>
|
||||
<!-- Key details — all visible above the fold on mobile -->
|
||||
<div class="space-y-1.5 pl-1">
|
||||
{#if $lq__lead_obj.event_badge_professional_title || $lq__lead_obj.event_badge_professional_title_override}
|
||||
<div class="flex items-center gap-2 text-sm opacity-80">
|
||||
<Briefcase size="1em" class="flex-none opacity-60" />
|
||||
<span>{@html $lq__lead_obj.event_badge_professional_title || $lq__lead_obj.event_badge_professional_title_override}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $lq__lead_obj.event_badge_affiliations || $lq__lead_obj.event_badge_affiliations_override}
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-primary-500">
|
||||
<MapPin size="1em" class="flex-none" />
|
||||
<span>{@html $lq__lead_obj.event_badge_affiliations || $lq__lead_obj.event_badge_affiliations_override}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 text-sm opacity-70">
|
||||
<Mail size="1em" class="flex-none" />
|
||||
<span class="font-mono truncate">{$lq__lead_obj.event_badge_email || 'No email on file'}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="bg-surface-200-800 p-2 rounded-lg"><CalendarDays size="1.2em" class="opacity-70" /></div>
|
||||
<div>
|
||||
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest">Captured On</div>
|
||||
<div>{format_date($lq__lead_obj.created_on)}</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm opacity-60">
|
||||
<CalendarDays size="1em" class="flex-none" />
|
||||
<span>Captured {format_date($lq__lead_obj.created_on)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,8 +151,9 @@
|
||||
</div>
|
||||
|
||||
{#if is_edit_mode}
|
||||
<Comp_lead_detail_form
|
||||
<Comp_lead_detail_form
|
||||
exhibit_tracking_id={exhibit_tracking_id ?? ''}
|
||||
exhibit_id={page.params.exhibit_id ?? ''}
|
||||
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ?? '[]'}
|
||||
current_responses_json={$lq__lead_obj.responses_json ?? '{}'}
|
||||
/>
|
||||
@@ -166,9 +162,10 @@
|
||||
{#if Object.keys(responses).length > 0}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 animate-in fade-in">
|
||||
{#each Object.entries(responses) as [question, answer]}
|
||||
{@const display_value = (answer !== null && typeof answer === 'object') ? (answer as any).response ?? '' : String(answer ?? '')}
|
||||
<div class="p-3 bg-surface-500/5 rounded-lg border border-surface-500/10">
|
||||
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest mb-1 leading-tight">{question}</div>
|
||||
<div class="font-semibold text-sm">{answer}</div>
|
||||
<div class="font-semibold text-sm">{display_value || '—'}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -220,10 +217,12 @@
|
||||
<h3 class="font-bold uppercase text-xs tracking-widest">Exhibit Context</h3>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm opacity-60">Exhibit Name</span>
|
||||
<span class="font-bold">{$lq__lead_obj.event_exhibit_name || '...'}</span>
|
||||
</div>
|
||||
{#if is_edit_mode}
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm opacity-60">Exhibit Name</span>
|
||||
<span class="font-bold">{$lq__lead_obj.event_exhibit_name || '...'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm opacity-60">Captured By</span>
|
||||
<span class="font-mono text-[10px]">{$lq__lead_obj.external_person_id || 'Unknown'}</span>
|
||||
|
||||
@@ -2,6 +2,21 @@
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/ae_comp__lead_detail_form.svelte
|
||||
* Lead Detail Form - Dynamic Custom Questions Editor.
|
||||
*
|
||||
* Question schema (event_exhibit.leads_custom_questions_json):
|
||||
* [{ code, question, type, option_li }]
|
||||
* - code: machine key — used as the property name in responses_json
|
||||
* - question: human-readable label shown to the exhibitor during capture/review
|
||||
* - type: 'text' | 'textarea' | 'toggle' | 'option'
|
||||
* - option_li: array of choices; first element is always '' (blank default)
|
||||
*
|
||||
* Response storage (event_exhibit_tracking.responses_json):
|
||||
* { [code]: { response: <value> } }
|
||||
* e.g. { "giveaway": { "response": "yes" }, "interest_level": { "response": "Hot" } }
|
||||
*
|
||||
* Backward compat: older questions may use `label` instead of `code`/`question`,
|
||||
* and older responses may store scalars directly (not wrapped in {response: ...}).
|
||||
* Both are handled transparently.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import { ae_api } from '$lib/stores/ae_stores';
|
||||
@@ -10,40 +25,66 @@
|
||||
|
||||
interface Props {
|
||||
exhibit_tracking_id: string;
|
||||
exhibit_id: string;
|
||||
custom_questions_json?: string; // From event_exhibit
|
||||
current_responses_json?: string; // From event_exhibit_tracking
|
||||
}
|
||||
|
||||
let { exhibit_tracking_id, custom_questions_json = '[]', current_responses_json = '{}' }: Props = $props();
|
||||
let { exhibit_tracking_id, exhibit_id, custom_questions_json = '[]', current_responses_json = '{}' }: Props = $props();
|
||||
|
||||
let question_defs: any[] = $state([]);
|
||||
let responses: Record<string, any> = $state({});
|
||||
// flat_responses: keyed by question code, stores scalar values for form binding.
|
||||
// We unwrap the nested {response: value} format on load and re-wrap on save.
|
||||
let flat_responses: Record<string, any> = $state({});
|
||||
let status = $state('idle'); // idle, saving, success
|
||||
|
||||
// Initialize data
|
||||
$effect(() => {
|
||||
try {
|
||||
// Handle both string and pre-parsed array/object
|
||||
question_defs = typeof custom_questions_json === 'string' ? JSON.parse(custom_questions_json || '[]') : (custom_questions_json || []);
|
||||
const parsed_responses = typeof current_responses_json === 'string' ? JSON.parse(current_responses_json || '{}') : (current_responses_json || {});
|
||||
const defs = typeof custom_questions_json === 'string'
|
||||
? JSON.parse(custom_questions_json || '[]')
|
||||
: (custom_questions_json || []);
|
||||
const raw = typeof current_responses_json === 'string'
|
||||
? JSON.parse(current_responses_json || '{}')
|
||||
: (current_responses_json || {});
|
||||
|
||||
untrack(() => {
|
||||
responses = parsed_responses;
|
||||
question_defs = defs;
|
||||
// Flatten: unwrap {response: value} → scalar for form binding
|
||||
const flat: Record<string, any> = {};
|
||||
for (const [key, val] of Object.entries(raw)) {
|
||||
if (val !== null && typeof val === 'object' && 'response' in (val as object)) {
|
||||
flat[key] = (val as any).response ?? '';
|
||||
} else {
|
||||
flat[key] = val ?? ''; // legacy scalar
|
||||
}
|
||||
}
|
||||
flat_responses = flat;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to parse questions/responses', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve the key for a question def (new: q.code, legacy: q.label)
|
||||
function q_key(q: any): string {
|
||||
return q.code || q.label || '';
|
||||
}
|
||||
|
||||
async function handle_save() {
|
||||
if (!exhibit_tracking_id) return;
|
||||
status = 'saving';
|
||||
try {
|
||||
// Re-wrap scalar values back to {response: value} format before saving
|
||||
const nested: Record<string, any> = {};
|
||||
for (const [key, val] of Object.entries(flat_responses)) {
|
||||
nested[key] = { response: val };
|
||||
}
|
||||
await events_func.update_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: exhibit_id,
|
||||
exhibit_tracking_id: exhibit_tracking_id,
|
||||
data: {
|
||||
responses_json: JSON.stringify(responses)
|
||||
responses_json: JSON.stringify(nested)
|
||||
}
|
||||
});
|
||||
status = 'success';
|
||||
@@ -58,13 +99,15 @@
|
||||
<div class="lead-detail-form space-y-6">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{#each question_defs as q}
|
||||
{@const key = q_key(q)}
|
||||
{@const display = q.question || q.label || key}
|
||||
<div class="space-y-2">
|
||||
<label class="label">
|
||||
<span class="text-[10px] uppercase font-black opacity-40 tracking-widest ml-1">{q.label}</span>
|
||||
<span class="text-[10px] uppercase font-black opacity-40 tracking-widest ml-1">{display}</span>
|
||||
|
||||
{#if q.type === 'textarea'}
|
||||
<textarea
|
||||
bind:value={responses[q.label]}
|
||||
bind:value={flat_responses[key]}
|
||||
class="textarea variant-filled-surface rounded-lg p-3 text-sm"
|
||||
rows="3"
|
||||
placeholder="Type response..."
|
||||
@@ -74,27 +117,35 @@
|
||||
<div class="flex items-center gap-4 p-3 variant-soft rounded-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={responses[q.label]}
|
||||
bind:checked={flat_responses[key]}
|
||||
class="checkbox"
|
||||
/>
|
||||
<span class="text-sm font-bold">{responses[q.label] ? 'Yes' : 'No'}</span>
|
||||
<span class="text-sm font-bold">{flat_responses[key] ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
|
||||
{:else if q.type === 'select'}
|
||||
{:else if q.type === 'option' || q.type === 'select'}
|
||||
<!-- type 'option' is the current schema; 'select' is legacy compat -->
|
||||
<select
|
||||
bind:value={responses[q.label]}
|
||||
bind:value={flat_responses[key]}
|
||||
class="select variant-filled-surface rounded-lg p-3 text-sm"
|
||||
>
|
||||
<option value="">-- Select Option --</option>
|
||||
{#each (q.options || '').split(',').map((o: string) => o.trim()) as opt}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
{#if Array.isArray(q.option_li)}
|
||||
{#each q.option_li as opt}
|
||||
<option value={opt}>{opt || '-- Select --'}</option>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Legacy: options was a comma-separated string -->
|
||||
<option value="">-- Select Option --</option>
|
||||
{#each (q.options || '').split(',').map((o: string) => o.trim()) as opt}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={responses[q.label]}
|
||||
bind:value={flat_responses[key]}
|
||||
class="input variant-filled-surface rounded-lg p-3 text-sm"
|
||||
placeholder="Type response..."
|
||||
/>
|
||||
@@ -121,4 +172,4 @@
|
||||
<Save size="1.2em" class="mr-2" /> Save Responses
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user