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> {
|
}): Promise<ae_EventExhibit | null> {
|
||||||
const result = await api.create_nested_obj_v3({
|
const result = await api.create_nested_obj_v3({
|
||||||
api_cfg,
|
api_cfg,
|
||||||
for_obj_type: 'event',
|
parent_type: 'event',
|
||||||
for_obj_id: event_id,
|
parent_id: event_id,
|
||||||
obj_type: 'event_exhibit',
|
child_type: 'event_exhibit',
|
||||||
fields: { ...data_kv },
|
fields: { ...data_kv },
|
||||||
log_lvl
|
log_lvl
|
||||||
});
|
});
|
||||||
@@ -353,10 +353,10 @@ export async function update_ae_obj__exhibit({
|
|||||||
}): Promise<ae_EventExhibit | null> {
|
}): Promise<ae_EventExhibit | null> {
|
||||||
const result = await api.update_nested_obj_v3({
|
const result = await api.update_nested_obj_v3({
|
||||||
api_cfg,
|
api_cfg,
|
||||||
for_obj_type: 'event',
|
parent_type: 'event',
|
||||||
for_obj_id: event_id,
|
parent_id: event_id,
|
||||||
obj_type: 'event_exhibit',
|
child_type: 'event_exhibit',
|
||||||
obj_id: exhibit_id,
|
child_id: exhibit_id,
|
||||||
fields: data_kv,
|
fields: data_kv,
|
||||||
log_lvl
|
log_lvl
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -326,9 +326,9 @@ export async function create_ae_obj__exhibit_tracking({
|
|||||||
}): Promise<ae_EventExhibitTracking | null> {
|
}): Promise<ae_EventExhibitTracking | null> {
|
||||||
const result = await api.create_nested_obj_v3({
|
const result = await api.create_nested_obj_v3({
|
||||||
api_cfg,
|
api_cfg,
|
||||||
for_obj_type: 'event_exhibit',
|
parent_type: 'event_exhibit',
|
||||||
for_obj_id: exhibit_id,
|
parent_id: exhibit_id,
|
||||||
obj_type: 'event_exhibit_tracking',
|
child_type: 'event_exhibit_tracking',
|
||||||
fields: {
|
fields: {
|
||||||
event_badge_id: event_badge_id,
|
event_badge_id: event_badge_id,
|
||||||
external_person_id,
|
external_person_id,
|
||||||
@@ -374,10 +374,10 @@ export async function update_ae_obj__exhibit_tracking({
|
|||||||
}): Promise<ae_EventExhibitTracking | null> {
|
}): Promise<ae_EventExhibitTracking | null> {
|
||||||
const result = await api.update_nested_obj_v3({
|
const result = await api.update_nested_obj_v3({
|
||||||
api_cfg,
|
api_cfg,
|
||||||
for_obj_type: 'event_exhibit',
|
parent_type: 'event_exhibit',
|
||||||
for_obj_id: exhibit_id,
|
parent_id: exhibit_id,
|
||||||
obj_type: 'event_exhibit_tracking',
|
child_type: 'event_exhibit_tracking',
|
||||||
obj_id: exhibit_tracking_id,
|
child_id: exhibit_tracking_id,
|
||||||
fields: data,
|
fields: data,
|
||||||
log_lvl
|
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 {
|
import {
|
||||||
Library,
|
Library,
|
||||||
RemoveFormatting,
|
RemoveFormatting,
|
||||||
X,
|
|
||||||
QrCode,
|
QrCode,
|
||||||
Search,
|
Search,
|
||||||
Check,
|
|
||||||
LoaderCircle
|
LoaderCircle
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
|
import { ae_loc, ae_api } from '$lib/stores/ae_stores';
|
||||||
import { events_loc, events_sess } from '$lib/stores/ae_events_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';
|
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||||
|
|
||||||
// ISHLT 2024 badge type codes
|
// 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 qr_scan_result = event.detail.result;
|
||||||
let obj = ae_util.process_data_string(qr_scan_result);
|
let obj = ae_util.process_data_string(qr_scan_result);
|
||||||
|
|
||||||
@@ -202,9 +200,9 @@
|
|||||||
<div
|
<div
|
||||||
class="w-full max-w-2xl mx-auto p-4 bg-surface-100-900 rounded-lg shadow-lg"
|
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}
|
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -240,24 +238,7 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
{#if $ae_loc.edit_mode}
|
||||||
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}
|
|
||||||
<label
|
<label
|
||||||
class="flex items-center gap-1 cursor-pointer bg-surface-200-800 px-2 py-1 rounded-token text-xs font-semibold"
|
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 { ae_util } from '$lib/ae_utils/ae_utils';
|
||||||
import {
|
import {
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
UserPlus,
|
|
||||||
Download,
|
Download,
|
||||||
Settings,
|
Settings,
|
||||||
Plus,
|
Plus,
|
||||||
List as ListIcon,
|
List as ListIcon,
|
||||||
LogIn,
|
LayoutGrid
|
||||||
LayoutGrid,
|
|
||||||
Search,
|
|
||||||
LogOut
|
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import Comp_exhibit_tracking_search from './ae_comp__exhibit_tracking_search.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';
|
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>
|
</script>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
@@ -449,15 +436,6 @@
|
|||||||
<Settings size="1.25em" />
|
<Settings size="1.25em" />
|
||||||
</button>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -6,40 +6,59 @@
|
|||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { ae_api } from '$lib/stores/ae_stores';
|
import { ae_api } from '$lib/stores/ae_stores';
|
||||||
import { events_func } from '$lib/ae_events_functions';
|
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 {
|
interface Props {
|
||||||
exhibit_id: string;
|
exhibit_id: string;
|
||||||
|
event_id: string;
|
||||||
custom_questions_json?: 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 questions: any[] = $state([]);
|
||||||
let is_saving = $state(false);
|
let is_saving = $state(false);
|
||||||
|
// Track the JSON as it was last saved so we can detect unsaved changes
|
||||||
|
let saved_json = $state('[]');
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
const incoming = custom_questions_json; // reactive dependency
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(custom_questions_json || '[]');
|
const parsed = JSON.parse(incoming || '[]');
|
||||||
untrack(() => {
|
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) {
|
} 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() {
|
async function save_questions() {
|
||||||
if (!exhibit_id) return;
|
if (!exhibit_id) return;
|
||||||
is_saving = true;
|
is_saving = true;
|
||||||
try {
|
try {
|
||||||
await events_func.update_ae_obj__exhibit({
|
await events_func.update_ae_obj__exhibit({
|
||||||
api_cfg: $ae_api,
|
api_cfg: $ae_api,
|
||||||
|
event_id: event_id,
|
||||||
exhibit_id: exhibit_id,
|
exhibit_id: exhibit_id,
|
||||||
data_kv: {
|
data_kv: {
|
||||||
leads_custom_questions_json: JSON.stringify(questions)
|
leads_custom_questions_json: JSON.stringify(questions)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
saved_json = JSON.stringify(questions);
|
||||||
} finally {
|
} finally {
|
||||||
is_saving = false;
|
is_saving = false;
|
||||||
}
|
}
|
||||||
@@ -47,16 +66,30 @@
|
|||||||
|
|
||||||
function add_question() {
|
function add_question() {
|
||||||
questions.push({
|
questions.push({
|
||||||
id: Math.random().toString(36).substring(2, 9),
|
// code: machine key used as the responses_json property name
|
||||||
label: '',
|
// 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',
|
type: 'text',
|
||||||
options: ''
|
option_li: ['']
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove_question(index: number) {
|
function remove_question(index: number) {
|
||||||
questions.splice(index, 1);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="custom-questions-editor space-y-4">
|
<div class="custom-questions-editor space-y-4">
|
||||||
@@ -67,42 +100,59 @@
|
|||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each questions as q, i}
|
{#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">
|
<div class="card p-4 variant-soft border border-surface-500/10 space-y-3 animate-in fade-in slide-in-from-right-2">
|
||||||
<button
|
<!-- Question header row: number + delete (always visible for mobile) -->
|
||||||
class="absolute top-2 right-2 p-2 text-error-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
<div class="flex items-center justify-between">
|
||||||
onclick={() => remove_question(i)}
|
<span class="text-[10px] uppercase font-black opacity-30 tracking-widest">Question {i + 1}</span>
|
||||||
>
|
<button
|
||||||
<Trash2 size="1.2em" />
|
class="btn btn-sm variant-ghost-error px-2 py-1"
|
||||||
</button>
|
onclick={() => remove_question(i)}
|
||||||
|
title="Remove question"
|
||||||
|
>
|
||||||
|
<Trash2 size="1em" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<!-- Label -->
|
<!-- Question / display label -->
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<label class="text-[10px] uppercase font-bold opacity-40">Question / Label</label>
|
<label class="text-[10px] uppercase font-bold opacity-40">Question / Label</label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<MessageSquare size="1em" class="opacity-30" />
|
<MessageSquare size="1em" class="opacity-30 flex-none" />
|
||||||
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Type -->
|
<!-- Code / machine key -->
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<label class="text-[10px] uppercase font-bold opacity-40">Response Type</label>
|
<label class="text-[10px] uppercase font-bold opacity-40">Field Code <span class="normal-case font-normal opacity-70">(key in export)</span></label>
|
||||||
<select bind:value={q.type} class="select variant-filled-surface text-xs p-1 rounded">
|
<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" />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
<List size="1em" class="opacity-30" />
|
<List size="1em" class="opacity-30 flex-none" />
|
||||||
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -112,22 +162,33 @@
|
|||||||
{#if questions.length === 0}
|
{#if questions.length === 0}
|
||||||
<div class="p-8 text-center border-2 border-dashed border-surface-500/20 rounded-xl opacity-30">
|
<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" />
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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">
|
<div class="flex gap-2 pt-2">
|
||||||
<button class="btn btn-sm variant-filled-secondary flex-1" onclick={add_question}>
|
<button class="btn btn-sm variant-filled-secondary flex-1" onclick={add_question}>
|
||||||
<Plus size="1.2em" class="mr-2" /> Add Question
|
<Plus size="1.2em" class="mr-2" /> Add Question
|
||||||
</button>
|
</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}
|
{#if is_saving}
|
||||||
<LoaderCircle size="1.2em" class="animate-spin mr-2" />
|
<LoaderCircle size="1.2em" class="animate-spin mr-2" />
|
||||||
{:else}
|
{:else}
|
||||||
<Save size="1.2em" class="mr-2" />
|
<Save size="1.2em" class="mr-2" />
|
||||||
{/if}
|
{/if}
|
||||||
Save Questions
|
{is_dirty ? 'Save Questions' : 'Saved'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,11 +10,12 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exhibit_id: string;
|
exhibit_id: string;
|
||||||
|
event_id: string;
|
||||||
license_li_json?: string; // Raw JSON string from DB
|
license_li_json?: string; // Raw JSON string from DB
|
||||||
license_max?: number;
|
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
|
// Local state for the parsed list
|
||||||
let local_license_li: any[] = $state([]);
|
let local_license_li: any[] = $state([]);
|
||||||
@@ -55,6 +56,7 @@
|
|||||||
const json_str = JSON.stringify(local_license_li);
|
const json_str = JSON.stringify(local_license_li);
|
||||||
await events_func.update_ae_obj__exhibit({
|
await events_func.update_ae_obj__exhibit({
|
||||||
api_cfg: $ae_api,
|
api_cfg: $ae_api,
|
||||||
|
event_id: event_id,
|
||||||
exhibit_id: exhibit_id,
|
exhibit_id: exhibit_id,
|
||||||
data_kv: {
|
data_kv: {
|
||||||
license_li_json: json_str
|
license_li_json: json_str
|
||||||
|
|||||||
@@ -17,10 +17,47 @@
|
|||||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
|
||||||
// Helper to format date
|
// Full ISO datetime for tooltip (hover title)
|
||||||
function format_date(date_str: string) {
|
function format_date_full(date_str: string) {
|
||||||
if (!date_str) return '';
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -75,9 +112,12 @@
|
|||||||
{event_tracking_obj.event_badge_affiliations}
|
{event_tracking_obj.event_badge_affiliations}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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" />
|
<Clock size="1em" />
|
||||||
{format_date(event_tracking_obj.created_on)}
|
{fuzzy_time_ago(event_tracking_obj.created_on)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -41,11 +41,16 @@
|
|||||||
let search_query = $state('');
|
let search_query = $state('');
|
||||||
let results: ae_EventBadge[] = $state([]);
|
let results: ae_EventBadge[] = $state([]);
|
||||||
let searching = $state(false);
|
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() {
|
async function handle_search() {
|
||||||
if (!search_query.trim()) return;
|
if (!search_query.trim()) return;
|
||||||
searching = true;
|
searching = true;
|
||||||
|
add_error_id = '';
|
||||||
try {
|
try {
|
||||||
const search_results = await events_func.search__event_badge({
|
const search_results = await events_func.search__event_badge({
|
||||||
api_cfg: $ae_api,
|
api_cfg: $ae_api,
|
||||||
@@ -62,25 +67,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function add_as_lead(badge: ae_EventBadge) {
|
async function add_as_lead(badge: ae_EventBadge) {
|
||||||
if (!badge.event_badge_id_random) return;
|
// Use id or id_random — whichever is populated from search results
|
||||||
adding_id = badge.event_badge_id_random;
|
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)
|
// 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';
|
const user_email = $events_loc.leads.auth_exhibit_kv?.[exhibit_id]?.key || 'shared_passcode';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await events_func.create_ae_obj__exhibit_tracking({
|
const result = await events_func.create_ae_obj__exhibit_tracking({
|
||||||
api_cfg: $ae_api,
|
api_cfg: $ae_api,
|
||||||
exhibit_id: exhibit_id,
|
exhibit_id: exhibit_id,
|
||||||
event_badge_id: badge.event_badge_id_random,
|
event_badge_id: badge_id,
|
||||||
external_person_id: user_email,
|
external_person_id: user_email,
|
||||||
group: user_email
|
group: user_email
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result && on_lead_added) { on_lead_added(badge);
|
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) {
|
} 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 {
|
} finally {
|
||||||
adding_id = '';
|
adding_id = '';
|
||||||
}
|
}
|
||||||
@@ -120,28 +140,36 @@
|
|||||||
{#if results.length > 0}
|
{#if results.length > 0}
|
||||||
<div class="results-list space-y-2 max-h-[50vh] overflow-y-auto pr-2">
|
<div class="results-list space-y-2 max-h-[50vh] overflow-y-auto pr-2">
|
||||||
{#each results as badge}
|
{#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 class="card p-3 flex justify-between items-center variant-soft shadow-sm">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-bold">{badge.full_name}</div>
|
<div class="font-bold">{badge.full_name}</div>
|
||||||
<div class="text-xs opacity-70">{badge.affiliations || badge.email || ''}</div>
|
<div class="text-xs opacity-70">{badge.affiliations || badge.email || ''}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if $existing_leads_map?.has(badge.event_badge_id_random)}
|
{#if existing_id}
|
||||||
<a
|
<a
|
||||||
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${$existing_leads_map.get(badge.event_badge_id_random)}`}
|
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${existing_id}`}
|
||||||
class="btn btn-sm variant-filled-secondary"
|
class="btn btn-sm variant-filled-secondary"
|
||||||
>
|
>
|
||||||
<Eye size="1em" class="mr-1" />
|
<Eye size="1em" class="mr-1" />
|
||||||
View
|
View
|
||||||
</a>
|
</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}
|
{:else}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm preset-filled-success"
|
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)}
|
onclick={() => add_as_lead(badge)}
|
||||||
>
|
>
|
||||||
{#if adding_id === badge.event_badge_id_random}
|
{#if adding_id === badge_id}
|
||||||
<LoaderCircle class="animate-spin" size="1em" />
|
<LoaderCircle class="animate-spin" size="1em" />
|
||||||
{:else}
|
{:else}
|
||||||
<UserPlus size="1em" class="mr-1" />
|
<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
|
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte
|
||||||
* Badge QR Scanner for adding leads.
|
* 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 { page } from '$app/state';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { liveQuery } from 'dexie';
|
import { liveQuery } from 'dexie';
|
||||||
import { db_events } from '$lib/ae_events/db_events';
|
import { db_events } from '$lib/ae_events/db_events';
|
||||||
import { ae_api } from '$lib/stores/ae_stores';
|
import { ae_api } from '$lib/stores/ae_stores';
|
||||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||||
import { events_func } from '$lib/ae_events_functions';
|
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 { ae_util } from '$lib/ae_utils/ae_utils';
|
||||||
import { LoaderCircle, UserPlus, CheckCircle, CircleAlert, Eye } from 'lucide-svelte';
|
import { LoaderCircle, UserPlus, CheckCircle, CircleAlert, Eye } from 'lucide-svelte';
|
||||||
import type { ae_EventBadge } from '$lib/types/ae_types';
|
import type { ae_EventBadge } from '$lib/types/ae_types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exhibit_id: string;
|
exhibit_id: string;
|
||||||
|
scan_qualify?: 'rapid' | 'qualify';
|
||||||
on_lead_added?: (badge: ae_EventBadge) => void;
|
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
|
// Track existing leads to prevent duplicates
|
||||||
let existing_leads_map = $derived(
|
let existing_leads_map = $derived(
|
||||||
@@ -42,9 +48,10 @@
|
|||||||
let scanning_status = $state('idle'); // idle, scanning, found, adding, success, error, already_added
|
let scanning_status = $state('idle'); // idle, scanning, found, adding, success, error, already_added
|
||||||
let found_badge: ae_EventBadge | null = $state(null);
|
let found_badge: ae_EventBadge | null = $state(null);
|
||||||
let existing_tracking_id = $state('');
|
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('');
|
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 qr_result = event.detail.result;
|
||||||
const obj = ae_util.process_data_string(qr_result);
|
const obj = ae_util.process_data_string(qr_result);
|
||||||
|
|
||||||
@@ -93,11 +100,18 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result) {
|
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';
|
scanning_status = 'success';
|
||||||
if (on_lead_added) on_lead_added(found_badge);
|
if (on_lead_added) on_lead_added(found_badge);
|
||||||
|
|
||||||
// Auto-reset after 2 seconds to scan next
|
if (scan_qualify === 'qualify' && new_tracking_id) {
|
||||||
setTimeout(reset_scanner, 2000);
|
// 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) {
|
} catch (e) {
|
||||||
scanning_status = 'error';
|
scanning_status = 'error';
|
||||||
@@ -108,17 +122,18 @@
|
|||||||
function reset_scanner() {
|
function reset_scanner() {
|
||||||
scanning_status = 'idle';
|
scanning_status = 'idle';
|
||||||
found_badge = null;
|
found_badge = null;
|
||||||
|
new_tracking_id = '';
|
||||||
error_msg = '';
|
error_msg = '';
|
||||||
start_qr_scanner = true;
|
start_qr_scanner = true;
|
||||||
}
|
}
|
||||||
</script>
|
</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'}
|
{#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">
|
<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
|
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 class="absolute inset-0 pointer-events-none border-2 border-primary-500/50 m-8 sm:m-12 rounded-lg animate-pulse"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,7 +204,16 @@
|
|||||||
<h3 class="h4 font-bold">Lead Added!</h3>
|
<h3 class="h4 font-bold">Lead Added!</h3>
|
||||||
<p class="text-xl font-bold">{found_badge?.full_name}</p>
|
<p class="text-xl font-bold">{found_badge?.full_name}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{:else if scanning_status === 'error'}
|
{:else if scanning_status === 'error'}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
/**
|
/**
|
||||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte
|
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte
|
||||||
* Tab 2: Add - Search / QR Scan Layout.
|
* 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_qr_scanner from './ae_comp__lead_qr_scanner.svelte';
|
||||||
import Comp_lead_manual_search from './ae_comp__lead_manual_search.svelte';
|
import Comp_lead_manual_search from './ae_comp__lead_manual_search.svelte';
|
||||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||||
@@ -14,7 +20,7 @@
|
|||||||
|
|
||||||
let { exhibit_id }: Props = $props();
|
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');
|
let mode = $derived($events_loc.leads.tab_add_mode?.[exhibit_id] ?? 'qr');
|
||||||
|
|
||||||
function set_mode(new_mode: string) {
|
function set_mode(new_mode: string) {
|
||||||
@@ -22,35 +28,77 @@
|
|||||||
$events_loc.leads.tab_add_mode[exhibit_id] = new_mode;
|
$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) {
|
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++;
|
$events_loc.leads.tracking__search_version++;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="ae-tab-add flex flex-col items-center space-y-6 w-full mx-auto">
|
<div class="ae-tab-add flex flex-col items-center space-y-4 w-full mx-auto">
|
||||||
<!-- Mode Toggle - Combined Button -->
|
|
||||||
<div class="flex justify-center w-full">
|
<!-- Controls Row: input mode toggle + scan qualify toggle -->
|
||||||
|
<div class="flex items-center gap-2 w-full">
|
||||||
|
|
||||||
|
<!-- QR / Search toggle (takes remaining space) -->
|
||||||
<button
|
<button
|
||||||
type="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"
|
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')}
|
onclick={() => set_mode(mode === 'qr' ? 'search' : 'qr')}
|
||||||
>
|
>
|
||||||
{#if mode === 'qr'}
|
{#if mode === 'qr'}
|
||||||
<Search size="1.2em" />
|
<Search size="1.1em" />
|
||||||
<span>Switch to Manual Search</span>
|
<span>Manual Search</span>
|
||||||
{:else}
|
{:else}
|
||||||
<QrCode size="1.2em" />
|
<QrCode size="1.1em" />
|
||||||
<span>Switch to QR Scan</span>
|
<span>QR Scan</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Content Area - Stable Width -->
|
<!-- Mode hint line -->
|
||||||
<div class="w-full flex flex-col items-center min-h-[400px]">
|
{#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'}
|
{#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}
|
{:else}
|
||||||
<Comp_lead_manual_search {exhibit_id} on_lead_added={handle_lead_added} />
|
<Comp_lead_manual_search {exhibit_id} on_lead_added={handle_lead_added} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
Key,
|
Key,
|
||||||
Users,
|
Users,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown
|
ChevronDown,
|
||||||
|
LogOut
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
const exhibit_id = $derived(page.params.exhibit_id ?? '');
|
const exhibit_id = $derived(page.params.exhibit_id ?? '');
|
||||||
@@ -40,6 +41,16 @@
|
|||||||
let show_license_mgmt = $state(false);
|
let show_license_mgmt = $state(false);
|
||||||
let show_custom_questions = $state(false);
|
let show_custom_questions = $state(false);
|
||||||
let show_billing = $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>
|
</script>
|
||||||
|
|
||||||
<div class="ae-tab-manage w-full space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-300 pb-20">
|
<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>
|
<p class="text-[9px] opacity-40 mt-2 italic">Official floor plan booth number.</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<!-- Section: Lead Settings -->
|
<!-- Section: Lead Settings -->
|
||||||
@@ -259,6 +280,7 @@
|
|||||||
<div class="p-4 bg-surface-500/5 border-t border-surface-500/10 animate-in fade-in slide-in-from-top-2">
|
<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
|
<Comp_exhibit_license_list
|
||||||
{exhibit_id}
|
{exhibit_id}
|
||||||
|
event_id={page.params.event_id ?? ''}
|
||||||
license_li_json={$lq__exhibit_obj?.license_li_json ?? '[]'}
|
license_li_json={$lq__exhibit_obj?.license_li_json ?? '[]'}
|
||||||
license_max={$lq__exhibit_obj?.license_max}
|
license_max={$lq__exhibit_obj?.license_max}
|
||||||
/>
|
/>
|
||||||
@@ -291,6 +313,7 @@
|
|||||||
<div class="p-4 bg-surface-500/5 border-t border-surface-500/10 animate-in fade-in slide-in-from-top-2">
|
<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
|
<Comp_exhibit_custom_questions
|
||||||
{exhibit_id}
|
{exhibit_id}
|
||||||
|
event_id={page.params.event_id ?? ''}
|
||||||
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ?? '[]'}
|
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ?? '[]'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,8 +329,8 @@
|
|||||||
<div class="flex items-center gap-4">
|
<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="bg-success-500/10 p-2 rounded-lg text-success-500"><CreditCard size="1.2em" /></div>
|
||||||
<div class="text-left">
|
<div class="text-left">
|
||||||
<div class="font-bold text-sm">Billing & Upgrades</div>
|
<div class="font-bold text-sm">Licenses & Billing</div>
|
||||||
<div class="text-xs opacity-50">Manage subscription and extra devices</div>
|
<div class="text-xs opacity-50">Review licenses and manage payment</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if show_billing}
|
{#if show_billing}
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
User,
|
User,
|
||||||
Mail,
|
Mail,
|
||||||
MapPin,
|
MapPin,
|
||||||
Clock,
|
|
||||||
FileText,
|
FileText,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Store,
|
Store,
|
||||||
@@ -24,7 +23,7 @@
|
|||||||
Star,
|
Star,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Edit,
|
SquarePen,
|
||||||
Eye
|
Eye
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
@@ -63,7 +62,7 @@
|
|||||||
<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">
|
<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">
|
<div class="flex items-center gap-4">
|
||||||
<a
|
<a
|
||||||
href={`/events/${page.params.event_id}/leads/exhibit/${$lq__lead_obj?.event_exhibit_id}`}
|
href={`/events/${page.params.event_id}/leads/exhibit/${page.params.exhibit_id}`}
|
||||||
class="btn btn-sm variant-ghost-surface"
|
class="btn btn-sm variant-ghost-surface"
|
||||||
>
|
>
|
||||||
<ChevronLeft size="1.2em" />
|
<ChevronLeft size="1.2em" />
|
||||||
@@ -83,7 +82,7 @@
|
|||||||
{#if is_edit_mode}
|
{#if is_edit_mode}
|
||||||
<Eye size="1.2em" class="mr-1" /> View
|
<Eye size="1.2em" class="mr-1" /> View
|
||||||
{:else}
|
{:else}
|
||||||
<Edit size="1.2em" class="mr-1" /> Edit
|
<SquarePen size="1.2em" class="mr-1" /> Edit
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -110,40 +109,36 @@
|
|||||||
<!-- Left: Profile Info -->
|
<!-- Left: Profile Info -->
|
||||||
<div class="lg:col-span-2 space-y-6">
|
<div class="lg:col-span-2 space-y-6">
|
||||||
<!-- Attendee Core Identity -->
|
<!-- Attendee Core Identity -->
|
||||||
<div class="card p-6 variant-soft shadow-lg border-l-4 border-primary-500">
|
<div class="card p-4 variant-soft shadow-lg border-l-4 border-primary-500 space-y-2">
|
||||||
<div class="flex flex-col sm:flex-row gap-6 items-center sm:items-start">
|
<!-- Name row: small inline icon -->
|
||||||
<div class="bg-primary-500/10 p-5 rounded-full flex-none">
|
<div class="flex items-center gap-2">
|
||||||
<User size="3.5em" class="text-primary-500" />
|
<User size="1.4em" class="text-primary-500 flex-none" />
|
||||||
</div>
|
<h2 class="text-2xl font-black leading-tight">
|
||||||
<div class="text-center sm:text-left space-y-1">
|
{@html $lq__lead_obj.event_badge_full_name || $lq__lead_obj.event_badge_full_name_override || 'Unknown Attendee'}
|
||||||
<h2 class="text-3xl font-black leading-tight">
|
</h2>
|
||||||
{$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>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8 pt-8 border-t border-surface-500/10">
|
<!-- Key details — all visible above the fold on mobile -->
|
||||||
<div class="flex items-center gap-4">
|
<div class="space-y-1.5 pl-1">
|
||||||
<div class="bg-surface-200-800 p-2 rounded-lg"><Mail size="1.2em" class="opacity-70" /></div>
|
{#if $lq__lead_obj.event_badge_professional_title || $lq__lead_obj.event_badge_professional_title_override}
|
||||||
<div class="min-w-0">
|
<div class="flex items-center gap-2 text-sm opacity-80">
|
||||||
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest">Email Address</div>
|
<Briefcase size="1em" class="flex-none opacity-60" />
|
||||||
<div class="font-mono truncate">{$lq__lead_obj.event_badge_email || 'N/A'}</div>
|
<span>{@html $lq__lead_obj.event_badge_professional_title || $lq__lead_obj.event_badge_professional_title_override}</span>
|
||||||
</div>
|
</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>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-2 text-sm opacity-60">
|
||||||
<div class="bg-surface-200-800 p-2 rounded-lg"><CalendarDays size="1.2em" class="opacity-70" /></div>
|
<CalendarDays size="1em" class="flex-none" />
|
||||||
<div>
|
<span>Captured {format_date($lq__lead_obj.created_on)}</span>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,6 +153,7 @@
|
|||||||
{#if is_edit_mode}
|
{#if is_edit_mode}
|
||||||
<Comp_lead_detail_form
|
<Comp_lead_detail_form
|
||||||
exhibit_tracking_id={exhibit_tracking_id ?? ''}
|
exhibit_tracking_id={exhibit_tracking_id ?? ''}
|
||||||
|
exhibit_id={page.params.exhibit_id ?? ''}
|
||||||
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ?? '[]'}
|
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ?? '[]'}
|
||||||
current_responses_json={$lq__lead_obj.responses_json ?? '{}'}
|
current_responses_json={$lq__lead_obj.responses_json ?? '{}'}
|
||||||
/>
|
/>
|
||||||
@@ -166,9 +162,10 @@
|
|||||||
{#if Object.keys(responses).length > 0}
|
{#if Object.keys(responses).length > 0}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 animate-in fade-in">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 animate-in fade-in">
|
||||||
{#each Object.entries(responses) as [question, answer]}
|
{#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="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="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>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -220,10 +217,12 @@
|
|||||||
<h3 class="font-bold uppercase text-xs tracking-widest">Exhibit Context</h3>
|
<h3 class="font-bold uppercase text-xs tracking-widest">Exhibit Context</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex justify-between items-center">
|
{#if is_edit_mode}
|
||||||
<span class="text-sm opacity-60">Exhibit Name</span>
|
<div class="flex justify-between items-center">
|
||||||
<span class="font-bold">{$lq__lead_obj.event_exhibit_name || '...'}</span>
|
<span class="text-sm opacity-60">Exhibit Name</span>
|
||||||
</div>
|
<span class="font-bold">{$lq__lead_obj.event_exhibit_name || '...'}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-sm opacity-60">Captured By</span>
|
<span class="text-sm opacity-60">Captured By</span>
|
||||||
<span class="font-mono text-[10px]">{$lq__lead_obj.external_person_id || 'Unknown'}</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
|
* 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.
|
* 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 { untrack } from 'svelte';
|
||||||
import { ae_api } from '$lib/stores/ae_stores';
|
import { ae_api } from '$lib/stores/ae_stores';
|
||||||
@@ -10,40 +25,66 @@
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
exhibit_tracking_id: string;
|
exhibit_tracking_id: string;
|
||||||
|
exhibit_id: string;
|
||||||
custom_questions_json?: string; // From event_exhibit
|
custom_questions_json?: string; // From event_exhibit
|
||||||
current_responses_json?: string; // From event_exhibit_tracking
|
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 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
|
let status = $state('idle'); // idle, saving, success
|
||||||
|
|
||||||
// Initialize data
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
try {
|
try {
|
||||||
// Handle both string and pre-parsed array/object
|
const defs = typeof custom_questions_json === 'string'
|
||||||
question_defs = typeof custom_questions_json === 'string' ? JSON.parse(custom_questions_json || '[]') : (custom_questions_json || []);
|
? JSON.parse(custom_questions_json || '[]')
|
||||||
const parsed_responses = typeof current_responses_json === 'string' ? JSON.parse(current_responses_json || '{}') : (current_responses_json || {});
|
: (custom_questions_json || []);
|
||||||
|
const raw = typeof current_responses_json === 'string'
|
||||||
|
? JSON.parse(current_responses_json || '{}')
|
||||||
|
: (current_responses_json || {});
|
||||||
|
|
||||||
untrack(() => {
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to parse questions/responses', 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() {
|
async function handle_save() {
|
||||||
if (!exhibit_tracking_id) return;
|
if (!exhibit_tracking_id) return;
|
||||||
status = 'saving';
|
status = 'saving';
|
||||||
try {
|
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({
|
await events_func.update_ae_obj__exhibit_tracking({
|
||||||
api_cfg: $ae_api,
|
api_cfg: $ae_api,
|
||||||
|
exhibit_id: exhibit_id,
|
||||||
exhibit_tracking_id: exhibit_tracking_id,
|
exhibit_tracking_id: exhibit_tracking_id,
|
||||||
data: {
|
data: {
|
||||||
responses_json: JSON.stringify(responses)
|
responses_json: JSON.stringify(nested)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
status = 'success';
|
status = 'success';
|
||||||
@@ -58,13 +99,15 @@
|
|||||||
<div class="lead-detail-form space-y-6">
|
<div class="lead-detail-form space-y-6">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
{#each question_defs as q}
|
{#each question_defs as q}
|
||||||
|
{@const key = q_key(q)}
|
||||||
|
{@const display = q.question || q.label || key}
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="label">
|
<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'}
|
{#if q.type === 'textarea'}
|
||||||
<textarea
|
<textarea
|
||||||
bind:value={responses[q.label]}
|
bind:value={flat_responses[key]}
|
||||||
class="textarea variant-filled-surface rounded-lg p-3 text-sm"
|
class="textarea variant-filled-surface rounded-lg p-3 text-sm"
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="Type response..."
|
placeholder="Type response..."
|
||||||
@@ -74,27 +117,35 @@
|
|||||||
<div class="flex items-center gap-4 p-3 variant-soft rounded-lg">
|
<div class="flex items-center gap-4 p-3 variant-soft rounded-lg">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={responses[q.label]}
|
bind:checked={flat_responses[key]}
|
||||||
class="checkbox"
|
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>
|
</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
|
<select
|
||||||
bind:value={responses[q.label]}
|
bind:value={flat_responses[key]}
|
||||||
class="select variant-filled-surface rounded-lg p-3 text-sm"
|
class="select variant-filled-surface rounded-lg p-3 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">-- Select Option --</option>
|
{#if Array.isArray(q.option_li)}
|
||||||
{#each (q.options || '').split(',').map((o: string) => o.trim()) as opt}
|
{#each q.option_li as opt}
|
||||||
<option value={opt}>{opt}</option>
|
<option value={opt}>{opt || '-- Select --'}</option>
|
||||||
{/each}
|
{/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>
|
</select>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={responses[q.label]}
|
bind:value={flat_responses[key]}
|
||||||
class="input variant-filled-surface rounded-lg p-3 text-sm"
|
class="input variant-filled-surface rounded-lg p-3 text-sm"
|
||||||
placeholder="Type response..."
|
placeholder="Type response..."
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user