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:
Scott Idem
2026-03-03 18:49:57 -05:00
parent 5c3823f41a
commit b064d8c235
14 changed files with 605 additions and 227 deletions

View File

@@ -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
});

View File

@@ -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
});

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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