Prettier for Event Exhibitor Leads

This commit is contained in:
Scott Idem
2026-03-24 12:14:30 -04:00
parent 7f6e286b73
commit 6d1d1e2658
24 changed files with 3116 additions and 2462 deletions

View File

@@ -4,6 +4,7 @@
**PWA only** — no Electron involvement. The Electron app is exclusively for the Launcher. **PWA only** — no Electron involvement. The Electron app is exclusively for the Launcher.
Spec docs: Spec docs:
- `documentation/PROJECT__AE_Events_Exhibitor_Leads_v3.md` — overview - `documentation/PROJECT__AE_Events_Exhibitor_Leads_v3.md` — overview
- `documentation/PROJECT__AE_Events_Exhibitor_Leads_v3_detail.md` — tab-level detail - `documentation/PROJECT__AE_Events_Exhibitor_Leads_v3_detail.md` — tab-level detail
@@ -31,36 +32,36 @@ All data is cached in IndexedDB (Dexie.js) for offline use, with background API
### Routes ### Routes
| File | Role | | File | Role |
| --- | --- | | -------------------------------------------------------------------- | ------------------------------------------- |
| `leads/+page.svelte` | Exhibit search/landing — find your booth | | `leads/+page.svelte` | Exhibit search/landing — find your booth |
| `leads/+page.ts` | Layout data load | | `leads/+page.ts` | Layout data load |
| `leads/exhibit/[exhibit_id]/+page.svelte` | Main exhibitor view — orchestrates all tabs | | `leads/exhibit/[exhibit_id]/+page.svelte` | Main exhibitor view — orchestrates all tabs |
| `leads/exhibit/[exhibit_id]/+layout.svelte` / `+layout.ts` | Exhibit layout / data load | | `leads/exhibit/[exhibit_id]/+layout.svelte` / `+layout.ts` | Exhibit layout / data load |
| `leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/+page.svelte` | Lead detail view/edit | | `leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/+page.svelte` | Lead detail view/edit |
| `leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/+page.ts` | Lead data load | | `leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/+page.ts` | Lead data load |
### Components (within `exhibit/[exhibit_id]/`) ### Components (within `exhibit/[exhibit_id]/`)
| File | Role | | File | Role |
| --- | --- | | ------------------------------------------ | ----------------------------------------------- |
| `ae_tab__start.svelte` | Tab 1 — welcome + sign-in | | `ae_tab__start.svelte` | Tab 1 — welcome + sign-in |
| `ae_tab__add.svelte` | Tab 2 — QR/search toggle + scan mode toggle | | `ae_tab__add.svelte` | Tab 2 — QR/search toggle + scan mode toggle |
| `ae_tab__manage.svelte` | Tab 4 — admin tools, booth config, app settings | | `ae_tab__manage.svelte` | Tab 4 — admin tools, booth config, app settings |
| `ae_comp__exhibit_signin.svelte` | Sign-in: shared passcode + licensed user | | `ae_comp__exhibit_signin.svelte` | Sign-in: shared passcode + licensed user |
| `ae_comp__lead_qr_scanner.svelte` | QR scanner (rapid vs. qualify mode) | | `ae_comp__lead_qr_scanner.svelte` | QR scanner (rapid vs. qualify mode) |
| `ae_comp__lead_manual_search.svelte` | Manual badge search + add | | `ae_comp__lead_manual_search.svelte` | Manual badge search + add |
| `ae_comp__exhibit_tracking_search.svelte` | Lead list search/filter bar | | `ae_comp__exhibit_tracking_search.svelte` | Lead list search/filter bar |
| `ae_comp__exhibit_tracking_obj_li.svelte` | Lead list item renderer | | `ae_comp__exhibit_tracking_obj_li.svelte` | Lead list item renderer |
| `ae_comp__exhibit_license_list.svelte` | License slot manager (admin) | | `ae_comp__exhibit_license_list.svelte` | License slot manager (admin) |
| `ae_comp__exhibit_custom_questions.svelte` | Custom question config editor (admin) | | `ae_comp__exhibit_custom_questions.svelte` | Custom question config editor (admin) |
| `ae_comp__exhibit_payment.svelte` | **STUB** — Stripe placeholder, not functional | | `ae_comp__exhibit_payment.svelte` | **STUB** — Stripe placeholder, not functional |
| `ae_comp__exhibit_search.svelte` | Exhibit search input on the landing page | | `ae_comp__exhibit_search.svelte` | Exhibit search input on the landing page |
### Lead detail components (within `lead/[exhibit_tracking_id]/`) ### Lead detail components (within `lead/[exhibit_tracking_id]/`)
| File | Role | | File | Role |
| --- | --- | | ---------------------------------- | ------------------------------- |
| `ae_comp__lead_detail_form.svelte` | Custom question response editor | | `ae_comp__lead_detail_form.svelte` | Custom question response editor |
--- ---
@@ -68,12 +69,14 @@ All data is cached in IndexedDB (Dexie.js) for offline use, with background API
## Data Model ## Data Model
### `event_exhibit` ### `event_exhibit`
Represents one exhibitor's presence at an event. Represents one exhibitor's presence at an event.
Key fields: `event_exhibit_id`, `name`, `code` (booth #), `staff_passcode`, `priority` (paid flag), Key fields: `event_exhibit_id`, `name`, `code` (booth #), `staff_passcode`, `priority` (paid flag),
`license_max`, `license_li_json` (array of `{full_name, email, passcode}`), `license_max`, `license_li_json` (array of `{full_name, email, passcode}`),
`leads_custom_questions_json` (array of question defs), `leads_device_sm_qty`, `leads_device_lg_qty`. `leads_custom_questions_json` (array of question defs), `leads_device_sm_qty`, `leads_device_lg_qty`.
### `event_exhibit_tracking` ### `event_exhibit_tracking`
One captured lead — links an exhibit to a badge. One captured lead — links an exhibit to a badge.
Key fields: `event_exhibit_tracking_id`, `event_exhibit_id`, `event_badge_id`, Key fields: `event_exhibit_tracking_id`, `event_exhibit_id`, `event_badge_id`,
`external_person_id` (capturer's email), `exhibitor_notes` (HTML), `external_person_id` (capturer's email), `exhibitor_notes` (HTML),
@@ -87,6 +90,7 @@ Denormalized badge fields: `event_badge_full_name`, `event_badge_email`,
## Sign-In Model ## Sign-In Model
Three auth levels in this module: Three auth levels in this module:
1. **Aether platform auth** (manager_access / trusted_access) — full admin bypass 1. **Aether platform auth** (manager_access / trusted_access) — full admin bypass
2. **Shared exhibit passcode** (`event_exhibit.staff_passcode`) — grants booth management access 2. **Shared exhibit passcode** (`event_exhibit.staff_passcode`) — grants booth management access
3. **Licensed user** (email + passcode from `license_li_json`) — grants lead capture access 3. **Licensed user** (email + passcode from `license_li_json`) — grants lead capture access
@@ -102,6 +106,7 @@ Scanner reads this, checks for duplicate in IDB, loads badge info, then creates
`event_exhibit_tracking` record via `events_func.create_ae_obj__exhibit_tracking`. `event_exhibit_tracking` record via `events_func.create_ae_obj__exhibit_tracking`.
Two scan modes (toggled per exhibit): Two scan modes (toggled per exhibit):
- **Rapid** — auto-resets after 2 seconds to scan the next person - **Rapid** — auto-resets after 2 seconds to scan the next person
- **Qualify** — navigates to lead detail immediately to fill in notes/responses - **Qualify** — navigates to lead detail immediately to fill in notes/responses

View File

@@ -1,11 +1,10 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
} }
let { children }: Props = $props(); let { children }: Props = $props();
// Basic layout for the leads module // Basic layout for the leads module
</script> </script>
{@render children?.()} {@render children?.()}

View File

@@ -1,254 +1,258 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
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 { import {
events_loc, events_loc,
events_sess, events_sess,
events_slct events_slct
} from '$lib/stores/ae_events_stores'; } from '$lib/stores/ae_events_stores';
import { ae_api, ae_loc } from '$lib/stores/ae_stores'; import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { page } from '$app/state'; import { page } from '$app/state';
import { events_func } from '$lib/ae_events/ae_events_functions'; import { events_func } from '$lib/ae_events/ae_events_functions';
import { LoaderCircle, Store } from '@lucide/svelte'; import { LoaderCircle, Store } from '@lucide/svelte';
import Comp_exhibit_search from './ae_comp__exhibit_search.svelte'; import Comp_exhibit_search from './ae_comp__exhibit_search.svelte';
// *** Initialization & Store Guard *** // *** Initialization & Store Guard ***
if ($events_loc.leads) { if ($events_loc.leads) {
if (typeof $events_loc.leads.search_version === 'undefined') if (typeof $events_loc.leads.search_version === 'undefined')
$events_loc.leads.search_version = 0; $events_loc.leads.search_version = 0;
if (typeof $events_loc.leads.qry__remote_first === 'undefined') if (typeof $events_loc.leads.qry__remote_first === 'undefined')
$events_loc.leads.qry__remote_first = false; $events_loc.leads.qry__remote_first = false;
if (typeof $events_loc.leads.qry__search_text === 'undefined') if (typeof $events_loc.leads.qry__search_text === 'undefined')
$events_loc.leads.qry__search_text = ''; $events_loc.leads.qry__search_text = '';
if (typeof $events_loc.leads.qry__sort_order === 'undefined') if (typeof $events_loc.leads.qry__sort_order === 'undefined')
$events_loc.leads.qry__sort_order = 'name_asc'; $events_loc.leads.qry__sort_order = 'name_asc';
} }
let exhibit_id_li: Array<string> = $state([]); let exhibit_id_li: Array<string> = $state([]);
let search_debounce_timer: any = null; let search_debounce_timer: any = null;
let last_search_id = 0; let last_search_id = 0;
let last_executed_key = ''; let last_executed_key = '';
let log_lvl = 0; let log_lvl = 0;
// Stable LiveQuery Pattern // Stable LiveQuery Pattern
let lq__event_exhibit_obj_li = $derived.by(() => { let lq__event_exhibit_obj_li = $derived.by(() => {
const ids = exhibit_id_li; const ids = exhibit_id_li;
const event_id = page.params.event_id; const event_id = page.params.event_id;
return liveQuery(async () => { return liveQuery(async () => {
// SCENARIO 1: Specific IDs provided (Search Results) // SCENARIO 1: Specific IDs provided (Search Results)
if (Array.isArray(ids) && ids.length > 0) { if (Array.isArray(ids) && ids.length > 0) {
const results = await db_events.exhibit.bulkGet(ids); const results = await db_events.exhibit.bulkGet(ids);
return results.filter((item) => item !== undefined); return results.filter((item) => item !== undefined);
}
// SCENARIO 2: Fallback broad search
if (event_id && !$events_loc.leads.qry__search_text) {
return await db_events.exhibit
.where('event_id')
.equals(event_id)
.sortBy('name');
}
return [];
});
});
// Standardized Reactive Search Pattern
let search_params = $derived({
v: $events_loc.leads.search_version,
str: ($events_loc.leads.qry__search_text ?? '').toLowerCase().trim(),
sort: $events_loc.leads.qry__sort_order,
event_id: page.params.event_id,
remote_first: $events_loc.leads.qry__remote_first
});
$effect(() => {
const params = search_params;
if (search_debounce_timer) clearTimeout(search_debounce_timer);
search_debounce_timer = setTimeout(() => {
untrack(() => {
handle_search_refresh(params);
});
}, 300);
return () => {
if (search_debounce_timer) clearTimeout(search_debounce_timer);
};
});
async function handle_search_refresh(params: any) {
const qry_key = JSON.stringify(params);
if (qry_key === last_executed_key) return;
last_executed_key = qry_key;
const current_search_id = ++last_search_id;
const event_id = params.event_id;
const remote_first = params.remote_first;
const qry_str = params.str;
if (!event_id) return;
// --- Search Constraint: Min 3 characters for non-trusted users ---
if (!$ae_loc.trusted_access && qry_str.length < 3) {
if (log_lvl) console.log('🛑 [Trace] Search string too short for public user.');
untrack(() => {
exhibit_id_li = [];
$events_sess.leads.submit_status__search = 'idle';
});
return;
} }
if (log_lvl) console.log(`🔎 [Trace] Exhibit Search #${current_search_id}: START (remote=${remote_first}, event=${event_id}, str=${params.str})`); // SCENARIO 2: Fallback broad search
if (event_id && !$events_loc.leads.qry__search_text) {
return await db_events.exhibit
.where('event_id')
.equals(event_id)
.sortBy('name');
}
return [];
});
});
// Standardized Reactive Search Pattern
let search_params = $derived({
v: $events_loc.leads.search_version,
str: ($events_loc.leads.qry__search_text ?? '').toLowerCase().trim(),
sort: $events_loc.leads.qry__sort_order,
event_id: page.params.event_id,
remote_first: $events_loc.leads.qry__remote_first
});
$effect(() => {
const params = search_params;
if (search_debounce_timer) clearTimeout(search_debounce_timer);
search_debounce_timer = setTimeout(() => {
untrack(() => { untrack(() => {
$events_sess.leads.submit_status__search = 'searching'; handle_search_refresh(params);
}); });
}, 300);
return () => {
if (search_debounce_timer) clearTimeout(search_debounce_timer);
};
});
// 1. FAST PATH: Local IDB Search async function handle_search_refresh(params: any) {
if (!remote_first) { const qry_key = JSON.stringify(params);
try { if (qry_key === last_executed_key) return;
let local_results = await db_events.exhibit last_executed_key = qry_key;
.where('event_id')
.equals(event_id)
.filter((exhibit) => {
// Priority Filter for Public
if (!$ae_loc.manager_access && !exhibit.priority) return false;
if (qry_str) { const current_search_id = ++last_search_id;
const name = (exhibit.name ?? '').toLowerCase(); const event_id = params.event_id;
const code = (exhibit.code ?? '').toLowerCase(); const remote_first = params.remote_first;
if ( const qry_str = params.str;
!name.includes(qry_str) &&
!code.includes(qry_str)
)
return false;
} else if (!$ae_loc.trusted_access) {
// Don't show default results to public if no search string
return false;
}
return true;
})
.toArray();
local_results.sort((a, b) => { if (!event_id) return;
switch (params.sort) {
case 'name_asc':
return (a.name ?? '').localeCompare(b.name ?? '');
case 'name_desc':
return (b.name ?? '').localeCompare(a.name ?? '');
case 'code_asc':
return (a.code ?? '').localeCompare(b.code ?? '');
case 'code_desc':
return (b.code ?? '').localeCompare(a.code ?? '');
case 'updated_desc':
return (
new Date(b.updated_on || 0).getTime() -
new Date(a.updated_on || 0).getTime()
);
default:
return (a.name ?? '').localeCompare(b.name ?? '');
}
});
const local_ids = local_results // --- Search Constraint: Min 3 characters for non-trusted users ---
.map((e) => String(e.id || e.event_exhibit_id)) if (!$ae_loc.trusted_access && qry_str.length < 3) {
.filter(Boolean); if (log_lvl)
console.log('🛑 [Trace] Search string too short for public user.');
untrack(() => {
exhibit_id_li = [];
$events_sess.leads.submit_status__search = 'idle';
});
return;
}
if (current_search_id === last_search_id) { if (log_lvl)
if (log_lvl) console.log(`✅ [Trace] Exhibit Search #${current_search_id}: Local path found ${local_ids.length} items.`); console.log(
untrack(() => { `🔎 [Trace] Exhibit Search #${current_search_id}: START (remote=${remote_first}, event=${event_id}, str=${params.str})`
exhibit_id_li = local_ids; );
});
}
} catch (e) {
console.warn('Exhibit Local Search failed.', e);
}
}
// 2. REVALIDATE: API Request untrack(() => {
$events_sess.leads.submit_status__search = 'searching';
});
// 1. FAST PATH: Local IDB Search
if (!remote_first) {
try { try {
let order_by_li: any = {}; let local_results = await db_events.exhibit
switch (params.sort) { .where('event_id')
case 'name_asc': .equals(event_id)
order_by_li = { name: 'ASC' }; .filter((exhibit) => {
break; // Priority Filter for Public
case 'name_desc': if (!$ae_loc.manager_access && !exhibit.priority)
order_by_li = { name: 'DESC' }; return false;
break;
case 'code_asc':
order_by_li = { code: 'ASC' };
break;
case 'code_desc':
order_by_li = { code: 'DESC' };
break;
case 'updated_desc':
order_by_li = { updated_on: 'DESC' };
break;
default:
order_by_li = { name: 'ASC' };
}
const results = await events_func.search__exhibit({ if (qry_str) {
api_cfg: $ae_api, const name = (exhibit.name ?? '').toLowerCase();
event_id: event_id, const code = (exhibit.code ?? '').toLowerCase();
fulltext_search_qry_str: qry_str || null, if (!name.includes(qry_str) && !code.includes(qry_str))
priority: $ae_loc.manager_access ? 'all' : 'priority', return false;
order_by_li, } else if (!$ae_loc.trusted_access) {
limit: 100 // Don't show default results to public if no search string
return false;
}
return true;
})
.toArray();
local_results.sort((a, b) => {
switch (params.sort) {
case 'name_asc':
return (a.name ?? '').localeCompare(b.name ?? '');
case 'name_desc':
return (b.name ?? '').localeCompare(a.name ?? '');
case 'code_asc':
return (a.code ?? '').localeCompare(b.code ?? '');
case 'code_desc':
return (b.code ?? '').localeCompare(a.code ?? '');
case 'updated_desc':
return (
new Date(b.updated_on || 0).getTime() -
new Date(a.updated_on || 0).getTime()
);
default:
return (a.name ?? '').localeCompare(b.name ?? '');
}
}); });
const local_ids = local_results
.map((e) => String(e.id || e.event_exhibit_id))
.filter(Boolean);
if (current_search_id === last_search_id) { if (current_search_id === last_search_id) {
const api_ids = results if (log_lvl)
.map((e: any) => String(e.id || e.event_exhibit_id)) console.log(
.filter(Boolean); `✅ [Trace] Exhibit Search #${current_search_id}: Local path found ${local_ids.length} items.`
);
if (log_lvl) console.log(`📦 [Trace] Exhibit Search #${current_search_id}: API revalidation found ${api_ids.length} items.`);
untrack(() => { untrack(() => {
exhibit_id_li = api_ids; exhibit_id_li = local_ids;
$events_sess.leads.submit_status__search = 'done';
});
}
} catch (error) {
if (current_search_id === last_search_id) {
console.error('Exhibit revalidation failed:', error);
untrack(() => {
$events_sess.leads.submit_status__search = 'error';
}); });
} }
} catch (e) {
console.warn('Exhibit Local Search failed.', e);
} }
} }
// 2. REVALIDATE: API Request
try {
let order_by_li: any = {};
switch (params.sort) {
case 'name_asc':
order_by_li = { name: 'ASC' };
break;
case 'name_desc':
order_by_li = { name: 'DESC' };
break;
case 'code_asc':
order_by_li = { code: 'ASC' };
break;
case 'code_desc':
order_by_li = { code: 'DESC' };
break;
case 'updated_desc':
order_by_li = { updated_on: 'DESC' };
break;
default:
order_by_li = { name: 'ASC' };
}
const results = await events_func.search__exhibit({
api_cfg: $ae_api,
event_id: event_id,
fulltext_search_qry_str: qry_str || null,
priority: $ae_loc.manager_access ? 'all' : 'priority',
order_by_li,
limit: 100
});
if (current_search_id === last_search_id) {
const api_ids = results
.map((e: any) => String(e.id || e.event_exhibit_id))
.filter(Boolean);
if (log_lvl)
console.log(
`📦 [Trace] Exhibit Search #${current_search_id}: API revalidation found ${api_ids.length} items.`
);
untrack(() => {
exhibit_id_li = api_ids;
$events_sess.leads.submit_status__search = 'done';
});
}
} catch (error) {
if (current_search_id === last_search_id) {
console.error('Exhibit revalidation failed:', error);
untrack(() => {
$events_sess.leads.submit_status__search = 'error';
});
}
}
}
</script> </script>
<section <section
class="ae_events_leads_new h-full w-full flex flex-col items-center space-y-4 p-4" class="ae_events_leads_new flex h-full w-full flex-col items-center space-y-4 p-4">
>
<h1 class="h2">Exhibitor Leads</h1> <h1 class="h2">Exhibitor Leads</h1>
<Comp_exhibit_search event_id={page.params.event_id ?? ''} /> <Comp_exhibit_search event_id={page.params.event_id ?? ''} />
{#if $events_sess.leads.submit_status__search === 'searching' && exhibit_id_li.length === 0} {#if $events_sess.leads.submit_status__search === 'searching' && exhibit_id_li.length === 0}
<div <div
class="flex flex-col items-center justify-center p-10 opacity-50 text-center" class="flex flex-col items-center justify-center p-10 text-center opacity-50">
> <LoaderCircle size="3em" class="mx-auto mb-4 animate-spin" />
<LoaderCircle size="3em" class="animate-spin mb-4 mx-auto" />
<p class="text-xl">Searching exhibits...</p> <p class="text-xl">Searching exhibits...</p>
</div> </div>
{:else if $lq__event_exhibit_obj_li && $lq__event_exhibit_obj_li.length > 0} {:else if $lq__event_exhibit_obj_li && $lq__event_exhibit_obj_li.length > 0}
<h2 class="h3">Select your exhibit from the list</h2> <h2 class="h3">Select your exhibit from the list</h2>
<div <div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-6xl" class="grid w-full max-w-6xl grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
>
{#each $lq__event_exhibit_obj_li as exhibit_obj (exhibit_obj.event_exhibit_id)} {#each $lq__event_exhibit_obj_li as exhibit_obj (exhibit_obj.event_exhibit_id)}
<!-- Force iframe mode (hides header/footer and passes exhibit_id via URL param) so the exhibit view can optimize for lead capture and hide irrelevant info. --> <!-- Force iframe mode (hides header/footer and passes exhibit_id via URL param) so the exhibit view can optimize for lead capture and hide irrelevant info. -->
<a <a
href="/events/{page.params href="/events/{page.params
.event_id}/leads/exhibit/{exhibit_obj.event_exhibit_id}?iframe=true" .event_id}/leads/exhibit/{exhibit_obj.event_exhibit_id}?iframe=true"
class="card card-hover p-4 flex flex-col items-center justify-center text-center space-y-2 preset-tonal" class="card card-hover preset-tonal flex flex-col items-center justify-center space-y-2 p-4 text-center">
>
<Store size="2em" /> <Store size="2em" />
<div class="font-bold text-lg">{exhibit_obj.name}</div> <div class="text-lg font-bold">{exhibit_obj.name}</div>
<div class="badge preset-filled-surface-500"> <div class="badge preset-filled-surface-500">
Booth #{exhibit_obj.code} Booth #{exhibit_obj.code}
</div> </div>
@@ -256,6 +260,6 @@
{/each} {/each}
</div> </div>
{:else} {:else}
<p class="opacity-50 mt-10">No exhibits found matching your search.</p> <p class="mt-10 opacity-50">No exhibits found matching your search.</p>
{/if} {/if}
</section> </section>

View File

@@ -1,44 +1,46 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
event_id: string; event_id: string;
log_lvl?: number; log_lvl?: number;
}
let { event_id, log_lvl = 0 }: Props = $props();
// *** Import other supporting libraries
import {
Library,
LoaderCircle,
RemoveFormatting,
Search
} from '@lucide/svelte';
import { ae_loc } from '$lib/stores/ae_stores';
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
function handle_search_trigger() {
if ($events_loc.leads.search_version === undefined) {
$events_loc.leads.search_version = 0;
} }
$events_loc.leads.search_version++;
}
let { event_id, log_lvl = 0 }: Props = $props(); function prevent_default<T extends Event>(fn: (event: T) => void) {
return function (event: T) {
// *** Import other supporting libraries event.preventDefault();
import { Library, LoaderCircle, RemoveFormatting, Search } from '@lucide/svelte'; fn(event);
import { ae_loc } from '$lib/stores/ae_stores'; };
import { events_loc, events_sess } from '$lib/stores/ae_events_stores'; }
function handle_search_trigger() {
if ($events_loc.leads.search_version === undefined) {
$events_loc.leads.search_version = 0;
}
$events_loc.leads.search_version++;
}
function prevent_default<T extends Event>(fn: (event: T) => void) {
return function (event: T) {
event.preventDefault();
fn(event);
};
}
</script> </script>
<div <div
class="ae_group filters_and_search flex flex-col items-center justify-center gap-2 w-full" class="ae_group filters_and_search flex w-full flex-col items-center justify-center gap-2">
>
<form <form
onsubmit={prevent_default(() => { onsubmit={prevent_default(() => {
handle_search_trigger(); handle_search_trigger();
})} })}
autocomplete="off" autocomplete="off"
class="search_form flex flex-row flex-wrap gap-1 items-center justify-center w-full max-w-7xl px-2 md:px-12 py-2 preset-tonal-success rounded-lg shadow-sm" class="search_form preset-tonal-success flex w-full max-w-7xl flex-row flex-wrap items-center justify-center gap-1 rounded-lg px-2 py-2 shadow-sm md:px-12">
>
<div <div
class="flex flex-col md:flex-row items-center justify-center gap-1 grow" class="flex grow flex-col items-center justify-center gap-1 md:flex-row">
>
<input <input
type="search" type="search"
placeholder="Exhibitor name or code..." placeholder="Exhibitor name or code..."
@@ -46,20 +48,18 @@
bind:value={$events_loc.leads.qry__search_text} bind:value={$events_loc.leads.qry__search_text}
autocomplete="off" autocomplete="off"
data-lpignore="true" data-lpignore="true"
class="input text-lg font-mono grow transition-all" class="input grow font-mono text-lg transition-all"
onkeyup={(e) => { onkeyup={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handle_search_trigger(); handle_search_trigger();
} }
}} }}
title="Search by name or code. Press Enter." title="Search by name or code. Press Enter." />
/>
<select <select
bind:value={$events_loc.leads.qry__sort_order} bind:value={$events_loc.leads.qry__sort_order}
onchange={handle_search_trigger} onchange={handle_search_trigger}
class="select select-sm text-xs px-1 max-w-fit" class="select select-sm max-w-fit px-1 text-xs">
>
<option value="name_asc">Name ASC</option> <option value="name_asc">Name ASC</option>
<option value="name_desc">Name DESC</option> <option value="name_desc">Name DESC</option>
<option value="code_asc">Booth # ASC</option> <option value="code_asc">Booth # ASC</option>
@@ -71,10 +71,9 @@
<div class="flex flex-row items-center justify-center gap-1"> <div class="flex flex-row items-center justify-center gap-1">
<button <button
type="submit" type="submit"
class="btn btn-lg preset-tonal-success border border-success-500 hover:preset-tonal-success text-2xl font-bold w-48 transition-all" class="btn btn-lg preset-tonal-success border-success-500 hover:preset-tonal-success w-48 border text-2xl font-bold transition-all">
>
{#if $events_sess.leads.submit_status__search === 'searching'} {#if $events_sess.leads.submit_status__search === 'searching'}
<LoaderCircle class="animate-spin mx-1" /> <LoaderCircle class="mx-1 animate-spin" />
{:else} {:else}
<Search class="mx-1" /> <Search class="mx-1" />
{/if} {/if}
@@ -88,9 +87,8 @@
$events_loc.leads.qry__search_text = ''; $events_loc.leads.qry__search_text = '';
handle_search_trigger(); handle_search_trigger();
}} }}
class="btn btn-sm text-xs preset-outlined-tertiary-100-900 hover:preset-filled-tertiary-100-900 transition-all" class="btn btn-sm preset-outlined-tertiary-100-900 hover:preset-filled-tertiary-100-900 text-xs transition-all"
title="Clear search query" title="Clear search query">
>
<RemoveFormatting size="1.25em" /> <RemoveFormatting size="1.25em" />
<span class="hidden md:inline"> Clear </span> <span class="hidden md:inline"> Clear </span>
</button> </button>
@@ -98,19 +96,16 @@
</form> </form>
<div <div
class="flex flex-row flex-wrap items-center justify-center gap-2 opacity-70 hover:opacity-100 transition-all" class="flex flex-row flex-wrap items-center justify-center gap-2 opacity-70 transition-all hover:opacity-100">
>
{#if $ae_loc.edit_mode} {#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="bg-surface-200-800 rounded-token flex cursor-pointer items-center gap-1 px-2 py-1 text-xs font-semibold">
>
<span> Remote First </span> <span> Remote First </span>
<input <input
type="checkbox" type="checkbox"
bind:checked={$events_loc.leads.qry__remote_first} bind:checked={$events_loc.leads.qry__remote_first}
onchange={handle_search_trigger} onchange={handle_search_trigger}
class="checkbox checkbox-sm" class="checkbox checkbox-sm" />
/>
</label> </label>
{/if} {/if}
</div> </div>

View File

@@ -1,26 +1,26 @@
<script lang="ts"> <script lang="ts">
/** /**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/+layout.svelte * src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/+layout.svelte
* Exhibitor Dashboard Layout. * Exhibitor Dashboard Layout.
*/ */
interface Props { interface Props {
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
} }
let { children }: Props = $props(); let { children }: Props = $props();
import { events_slct } from '$lib/stores/ae_events_stores'; import { events_slct } from '$lib/stores/ae_events_stores';
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';
let lq__exhibit_obj = $derived( let lq__exhibit_obj = $derived(
liveQuery(async () => { liveQuery(async () => {
if (!$events_slct.exhibit_id) return null; if (!$events_slct.exhibit_id) return null;
return await db_events.exhibit.get($events_slct.exhibit_id); return await db_events.exhibit.get($events_slct.exhibit_id);
}) })
); );
</script> </script>
<!-- <div class="exhibit-layout flex flex-col h-full w-full"> --> <!-- <div class="exhibit-layout flex flex-col h-full w-full"> -->
{@render children?.()} {@render children?.()}
<!-- </div> --> <!-- </div> -->

View File

@@ -13,7 +13,7 @@ export async function load({ params, parent }) {
const exhibit_id = params.exhibit_id; const exhibit_id = params.exhibit_id;
// Sync to store for components // Sync to store for components
events_slct.update(s => { events_slct.update((s) => {
s.exhibit_id = exhibit_id; s.exhibit_id = exhibit_id;
return s; return s;
}); });

View File

@@ -1,402 +1,427 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
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 { import {
events_loc, events_loc,
events_sess, events_sess,
events_slct events_slct
} from '$lib/stores/ae_events_stores'; } from '$lib/stores/ae_events_stores';
import { ae_api, ae_loc } from '$lib/stores/ae_stores'; import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { page } from '$app/state'; import { page } from '$app/state';
import { events_func } from '$lib/ae_events/ae_events_functions'; import { events_func } from '$lib/ae_events/ae_events_functions';
import { ae_util } from '$lib/ae_utils/ae_utils'; import { ae_util } from '$lib/ae_utils/ae_utils';
import { CreditCard, Download, LayoutGrid, List as ListIcon, LoaderCircle, Plus, Settings } from '@lucide/svelte'; import {
import Comp_exhibit_tracking_search from './ae_comp__exhibit_tracking_search.svelte'; CreditCard,
import Comp_exhibit_tracking_obj_li from './ae_comp__exhibit_tracking_obj_li.svelte'; Download,
import Tab_add from './ae_tab__add.svelte'; LayoutGrid,
import Tab_start from './ae_tab__start.svelte'; List as ListIcon,
import Tab_manage from './ae_tab__manage.svelte'; LoaderCircle,
import Comp_exhibit_payment from './ae_comp__exhibit_payment.svelte'; Plus,
Settings
} 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';
import Tab_add from './ae_tab__add.svelte';
import Tab_start from './ae_tab__start.svelte';
import Tab_manage from './ae_tab__manage.svelte';
import Comp_exhibit_payment from './ae_comp__exhibit_payment.svelte';
// *** Initialization & Store Guard *** // *** Initialization & Store Guard ***
if ($events_loc.leads) { if ($events_loc.leads) {
if (typeof $events_loc.leads.tracking__search_version === 'undefined') if (typeof $events_loc.leads.tracking__search_version === 'undefined')
$events_loc.leads.tracking__search_version = 0; $events_loc.leads.tracking__search_version = 0;
if ( if (typeof $events_loc.leads.tracking__qry__remote_first === 'undefined')
typeof $events_loc.leads.tracking__qry__remote_first === 'undefined' $events_loc.leads.tracking__qry__remote_first = false;
) if (typeof $events_loc.leads.tracking__qry__search_text === 'undefined')
$events_loc.leads.tracking__qry__remote_first = false; $events_loc.leads.tracking__qry__search_text = '';
if (typeof $events_loc.leads.tracking__qry__search_text === 'undefined') if (typeof $events_loc.leads.tracking__qry__sort_order === 'undefined')
$events_loc.leads.tracking__qry__search_text = ''; $events_loc.leads.tracking__qry__sort_order = 'created_desc';
if (typeof $events_loc.leads.tracking__qry__sort_order === 'undefined') if (typeof $events_loc.leads.refresh_interval_sec === 'undefined')
$events_loc.leads.tracking__qry__sort_order = 'created_desc'; $events_loc.leads.refresh_interval_sec = 25;
if (typeof $events_loc.leads.refresh_interval_sec === 'undefined') if (typeof $events_loc.leads.show_hidden === 'undefined')
$events_loc.leads.refresh_interval_sec = 25; $events_loc.leads.show_hidden = false;
if (typeof $events_loc.leads.show_hidden === 'undefined') }
$events_loc.leads.show_hidden = false;
// --- Sign-In State (Derived) ---
// 1. Manager Access (Bypass) OR 2. Valid Exhibit Auth entry
let is_signed_in = $derived(
$ae_loc.manager_access ||
!!$events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? '']
);
// --- Tab State (Sticky via Store) ---
let active_tab = $derived.by(() => {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return 'start';
const saved_tab = $events_loc.leads.tab?.[exhibit_id] ?? 'list';
// If signed in but stuck on start tab, go to list
if (is_signed_in && saved_tab === 'start') return 'list';
return saved_tab;
});
let previous_main_tab = $state('list'); // To remember if we were on 'add' or 'list' before going to 'manage'
function set_active_tab(new_tab: string) {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return;
if (!$events_loc.leads.tab) $events_loc.leads.tab = {};
$events_loc.leads.tab[exhibit_id] = new_tab;
}
let tracking_id_li: Array<string> = $state([]);
let search_debounce_timer: any = null;
let last_search_id = 0;
let last_executed_key = '';
let log_lvl = 0;
// --- NEW: Direct Reactive List Pattern ---
let raw_lead_li: any[] = $state([]);
// Final filtered list that the UI actually sees
// Applying the HARD GUARD here ensures that no matter where the data came from
// (API or IDB), it MUST match the selected licensee.
let filtered_lead_li = $derived.by(() => {
const licensee_filter = search_params.licensee_email;
const show_hidden = search_params.show_hidden;
return raw_lead_li.filter((lead) => {
// Never show disabled (removed) leads — enable=0/false means the exhibitor deleted them
if (lead.enable === 0 || lead.enable === false) return false;
// Exclude hidden leads unless show_hidden is toggled on
if (!show_hidden && lead.hide) return false;
if (licensee_filter === 'all') return true;
const capturer = lead.external_person_id || lead.group;
return capturer === licensee_filter;
});
});
// Subscribe to the Lead List
$effect(() => {
const ids = tracking_id_li;
const exhibit_id = page.params.exhibit_id;
const has_search = !!$events_loc.leads.tracking__qry__search_text;
const observable = liveQuery(async () => {
// 1. Specific IDs provided (from API Search or Manual Entry)
if (Array.isArray(ids) && ids.length > 0) {
const results = await db_events.exhibit_tracking.bulkGet(ids);
return results.filter((item) => item !== undefined);
}
// 2. Fallback broad search (Initial load or no search text)
if (exhibit_id && !has_search) {
return await db_events.exhibit_tracking
.where('event_exhibit_id')
.equals(exhibit_id)
.reverse()
.sortBy('created_on');
}
return [];
});
const subscription = observable.subscribe((res) => {
raw_lead_li = res;
});
return () => subscription.unsubscribe();
});
// Exhibit Info
const lq__exhibit_obj = liveQuery(() => {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return undefined;
return db_events.exhibit.get(exhibit_id);
});
// Standardized Reactive Search Pattern
let search_params = $derived.by(() => {
let licensee_email = $events_loc.leads.tracking__qry__licensee_email;
// Resolve "My Leads" to actual email
if (licensee_email === 'my') {
licensee_email =
$events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? '']
?.key || 'all';
} }
// --- Sign-In State (Derived) --- return {
// 1. Manager Access (Bypass) OR 2. Valid Exhibit Auth entry v: $events_loc.leads.tracking__search_version,
let is_signed_in = $derived( str: ($events_loc.leads.tracking__qry__search_text ?? '')
$ae_loc.manager_access || .toLowerCase()
!!$events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? ''] .trim(),
sort: $events_loc.leads.tracking__qry__sort_order,
licensee_email: licensee_email,
exhibit_id: page.params.exhibit_id,
remote_first: $events_loc.leads.tracking__qry__remote_first,
show_hidden: $events_loc.leads.show_hidden ?? false
};
});
$effect(() => {
const params = search_params;
if (search_debounce_timer) clearTimeout(search_debounce_timer);
search_debounce_timer = setTimeout(() => {
untrack(() => {
handle_search_refresh(params);
// Reset countdown on manual search
$events_sess.leads.next_refresh_countdown =
$events_loc.leads.refresh_interval_sec || 25;
});
}, 300);
return () => {
if (search_debounce_timer) clearTimeout(search_debounce_timer);
};
});
// --- Auto-Refresh Timer Logic ---
$effect(() => {
if (!is_signed_in) return;
const interval = setInterval(() => {
untrack(() => {
if ($events_sess.leads.next_refresh_countdown > 0) {
$events_sess.leads.next_refresh_countdown--;
} else {
// Trigger refresh
$events_loc.leads.tracking__search_version++;
$events_sess.leads.next_refresh_countdown =
$events_loc.leads.refresh_interval_sec || 25;
}
});
}, 1000);
return () => clearInterval(interval);
});
async function handle_search_refresh(params: any) {
const qry_key = JSON.stringify(params);
if (qry_key === last_executed_key) return;
last_executed_key = qry_key;
const current_search_id = ++last_search_id;
const exhibit_id = params.exhibit_id;
const remote_first = params.remote_first;
if (!exhibit_id) return;
if (log_lvl)
console.log(
`🔎 [Trace] Lead Search #${current_search_id}: START (remote=${remote_first}, exhibit=${exhibit_id}, str=${params.str})`
); );
// --- Tab State (Sticky via Store) --- untrack(() => {
let active_tab = $derived.by(() => { $events_sess.leads.submit_status__search = 'searching';
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return 'start';
const saved_tab = $events_loc.leads.tab?.[exhibit_id] ?? 'list';
// If signed in but stuck on start tab, go to list
if (is_signed_in && saved_tab === 'start') return 'list';
return saved_tab;
});
let previous_main_tab = $state('list'); // To remember if we were on 'add' or 'list' before going to 'manage'
function set_active_tab(new_tab: string) {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return;
if (!$events_loc.leads.tab) $events_loc.leads.tab = {};
$events_loc.leads.tab[exhibit_id] = new_tab;
}
let tracking_id_li: Array<string> = $state([]);
let search_debounce_timer: any = null;
let last_search_id = 0;
let last_executed_key = '';
let log_lvl = 0;
// --- NEW: Direct Reactive List Pattern ---
let raw_lead_li: any[] = $state([]);
// Final filtered list that the UI actually sees
// Applying the HARD GUARD here ensures that no matter where the data came from
// (API or IDB), it MUST match the selected licensee.
let filtered_lead_li = $derived.by(() => {
const licensee_filter = search_params.licensee_email;
const show_hidden = search_params.show_hidden;
return raw_lead_li.filter(lead => {
// Never show disabled (removed) leads — enable=0/false means the exhibitor deleted them
if (lead.enable === 0 || lead.enable === false) return false;
// Exclude hidden leads unless show_hidden is toggled on
if (!show_hidden && lead.hide) return false;
if (licensee_filter === 'all') return true;
const capturer = lead.external_person_id || lead.group;
return capturer === licensee_filter;
});
}); });
// Subscribe to the Lead List const qry_str = params.str;
$effect(() => {
const ids = tracking_id_li;
const exhibit_id = page.params.exhibit_id;
const has_search = !!$events_loc.leads.tracking__qry__search_text;
const observable = liveQuery(async () => { // 1. FAST PATH: Local IDB Search
// 1. Specific IDs provided (from API Search or Manual Entry) if (!remote_first) {
if (Array.isArray(ids) && ids.length > 0) {
const results = await db_events.exhibit_tracking.bulkGet(ids);
return results.filter((item) => item !== undefined);
}
// 2. Fallback broad search (Initial load or no search text)
if (exhibit_id && !has_search) {
return await db_events.exhibit_tracking
.where('event_exhibit_id')
.equals(exhibit_id)
.reverse()
.sortBy('created_on');
}
return [];
});
const subscription = observable.subscribe(res => {
raw_lead_li = res;
});
return () => subscription.unsubscribe();
});
// Exhibit Info
const lq__exhibit_obj = liveQuery(() => {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return undefined;
return db_events.exhibit.get(exhibit_id);
});
// Standardized Reactive Search Pattern
let search_params = $derived.by(() => {
let licensee_email = $events_loc.leads.tracking__qry__licensee_email;
// Resolve "My Leads" to actual email
if (licensee_email === 'my') {
licensee_email = $events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? '']?.key || 'all';
}
return {
v: $events_loc.leads.tracking__search_version,
str: ($events_loc.leads.tracking__qry__search_text ?? '')
.toLowerCase()
.trim(),
sort: $events_loc.leads.tracking__qry__sort_order,
licensee_email: licensee_email,
exhibit_id: page.params.exhibit_id,
remote_first: $events_loc.leads.tracking__qry__remote_first,
show_hidden: $events_loc.leads.show_hidden ?? false
};
});
$effect(() => {
const params = search_params;
if (search_debounce_timer) clearTimeout(search_debounce_timer);
search_debounce_timer = setTimeout(() => {
untrack(() => {
handle_search_refresh(params);
// Reset countdown on manual search
$events_sess.leads.next_refresh_countdown = $events_loc.leads.refresh_interval_sec || 25;
});
}, 300);
return () => {
if (search_debounce_timer) clearTimeout(search_debounce_timer);
};
});
// --- Auto-Refresh Timer Logic ---
$effect(() => {
if (!is_signed_in) return;
const interval = setInterval(() => {
untrack(() => {
if ($events_sess.leads.next_refresh_countdown > 0) {
$events_sess.leads.next_refresh_countdown--;
} else {
// Trigger refresh
$events_loc.leads.tracking__search_version++;
$events_sess.leads.next_refresh_countdown = $events_loc.leads.refresh_interval_sec || 25;
}
});
}, 1000);
return () => clearInterval(interval);
});
async function handle_search_refresh(params: any) {
const qry_key = JSON.stringify(params);
if (qry_key === last_executed_key) return;
last_executed_key = qry_key;
const current_search_id = ++last_search_id;
const exhibit_id = params.exhibit_id;
const remote_first = params.remote_first;
if (!exhibit_id) return;
if (log_lvl) console.log(`🔎 [Trace] Lead Search #${current_search_id}: START (remote=${remote_first}, exhibit=${exhibit_id}, str=${params.str})`);
untrack(() => {
$events_sess.leads.submit_status__search = 'searching';
});
const qry_str = params.str;
// 1. FAST PATH: Local IDB Search
if (!remote_first) {
try {
const target_exhibit_id = exhibit_id;
const target_licensee_email = params.licensee_email;
let local_results = await db_events.exhibit_tracking
.where('event_exhibit_id')
.equals(target_exhibit_id)
.filter((tracking) => {
// 0. Never include disabled (removed) records — they're soft-deleted
if (!tracking.enable) return false;
// 1. Hide filter — exclude hidden records unless show_hidden is on
if (!params.show_hidden && tracking.hide) return false;
// 2. Licensee Email Filter
if (target_licensee_email !== 'all') {
if (tracking.external_person_id !== target_licensee_email) return false;
}
if (qry_str) {
const name = (
tracking.event_badge_full_name ?? ''
).toLowerCase();
const email = (
tracking.event_badge_email ?? ''
).toLowerCase();
const notes = ae_util.strip_html(tracking.exhibitor_notes ?? '').toLowerCase();
// Guard: Prevent "undefined" from being searched
if (tracking.exhibitor_notes === 'undefined') {
tracking.exhibitor_notes = '';
}
const qry_string = (
tracking.default_qry_str ?? ''
).toLowerCase();
if (
!name.includes(qry_str) &&
!email.includes(qry_str) &&
!notes.includes(qry_str) &&
!qry_string.includes(qry_str)
)
return false;
}
return true;
})
.toArray();
local_results.sort((a, b) => {
switch (params.sort) {
case 'name_asc':
return (
a.event_badge_full_name ?? ''
).localeCompare(b.event_badge_full_name ?? '');
case 'name_desc':
return (
b.event_badge_full_name ?? ''
).localeCompare(a.event_badge_full_name ?? '');
case 'created_asc':
return (
new Date(a.created_on || 0).getTime() -
new Date(b.created_on || 0).getTime()
);
case 'created_desc':
return (
new Date(b.created_on || 0).getTime() -
new Date(a.created_on || 0).getTime()
);
default:
return (
new Date(b.created_on || 0).getTime() -
new Date(a.created_on || 0).getTime()
);
}
});
const local_ids = local_results
.map((e) =>
String(e.id || e.event_exhibit_tracking_id)
)
.filter(Boolean);
if (current_search_id === last_search_id) {
if (log_lvl) console.log(`✅ [Trace] Lead Search #${current_search_id}: Local path found ${local_ids.length} items.`);
untrack(() => {
tracking_id_li = local_ids;
});
}
} catch (e) {
console.warn('Exhibit Tracking Local Search failed.', e);
}
}
// 2. REVALIDATE: API Request
try { try {
let order_by_li: any = {}; const target_exhibit_id = exhibit_id;
switch (params.sort) { const target_licensee_email = params.licensee_email;
case 'name_asc':
order_by_li = { event_badge_full_name: 'ASC' };
break;
case 'name_desc':
order_by_li = { event_badge_full_name: 'DESC' };
break;
case 'created_asc':
order_by_li = { created_on: 'ASC' };
break;
case 'created_desc':
order_by_li = { created_on: 'DESC' };
break;
default:
order_by_li = { created_on: 'DESC' };
}
const q_event_id: string = page.params.event_id ?? ''; let local_results = await db_events.exhibit_tracking
const q_exhibit_id: string = exhibit_id ?? ''; .where('event_exhibit_id')
const q_licensee_email: string | null = (params.licensee_email !== 'all') ? (params.licensee_email ?? '') : null; .equals(target_exhibit_id)
.filter((tracking) => {
// 0. Never include disabled (removed) records — they're soft-deleted
if (!tracking.enable) return false;
// 1. Hide filter — exclude hidden records unless show_hidden is on
if (!params.show_hidden && tracking.hide) return false;
const results = await events_func.search__exhibit_tracking({ // 2. Licensee Email Filter
api_cfg: $ae_api, if (target_licensee_email !== 'all') {
event_id: q_event_id, if (
event_exhibit_id: q_exhibit_id, tracking.external_person_id !==
fulltext_search_qry_str: qry_str || null, target_licensee_email
qry_external_person_id: q_licensee_email, )
hidden: params.show_hidden ? 'all' : 'not_hidden', return false;
order_by_li, }
limit: 150
if (qry_str) {
const name = (
tracking.event_badge_full_name ?? ''
).toLowerCase();
const email = (
tracking.event_badge_email ?? ''
).toLowerCase();
const notes = ae_util
.strip_html(tracking.exhibitor_notes ?? '')
.toLowerCase();
// Guard: Prevent "undefined" from being searched
if (tracking.exhibitor_notes === 'undefined') {
tracking.exhibitor_notes = '';
}
const qry_string = (
tracking.default_qry_str ?? ''
).toLowerCase();
if (
!name.includes(qry_str) &&
!email.includes(qry_str) &&
!notes.includes(qry_str) &&
!qry_string.includes(qry_str)
)
return false;
}
return true;
})
.toArray();
local_results.sort((a, b) => {
switch (params.sort) {
case 'name_asc':
return (a.event_badge_full_name ?? '').localeCompare(
b.event_badge_full_name ?? ''
);
case 'name_desc':
return (b.event_badge_full_name ?? '').localeCompare(
a.event_badge_full_name ?? ''
);
case 'created_asc':
return (
new Date(a.created_on || 0).getTime() -
new Date(b.created_on || 0).getTime()
);
case 'created_desc':
return (
new Date(b.created_on || 0).getTime() -
new Date(a.created_on || 0).getTime()
);
default:
return (
new Date(b.created_on || 0).getTime() -
new Date(a.created_on || 0).getTime()
);
}
}); });
const local_ids = local_results
.map((e) => String(e.id || e.event_exhibit_tracking_id))
.filter(Boolean);
if (current_search_id === last_search_id) { if (current_search_id === last_search_id) {
const api_ids = results if (log_lvl)
.map((e: any) => console.log(
String(e.id || e.event_exhibit_tracking_id) `✅ [Trace] Lead Search #${current_search_id}: Local path found ${local_ids.length} items.`
) );
.filter(Boolean);
if (log_lvl) console.log(`📦 [Trace] Lead Search #${current_search_id}: API revalidation found ${api_ids.length} items.`);
untrack(() => { untrack(() => {
tracking_id_li = api_ids; tracking_id_li = local_ids;
$events_sess.leads.submit_status__search = 'done';
$events_sess.leads.last_refresh_time = new Date().toISOString();
});
}
} catch (error) {
if (current_search_id === last_search_id) {
console.error('Lead revalidation failed:', error);
untrack(() => {
$events_sess.leads.submit_status__search = 'error';
}); });
} }
} catch (e) {
console.warn('Exhibit Tracking Local Search failed.', e);
} }
} }
async function handle_export() { // 2. REVALIDATE: API Request
const exhibit_id = page.params.exhibit_id; try {
if (!exhibit_id) return; let order_by_li: any = {};
switch (params.sort) {
case 'name_asc':
order_by_li = { event_badge_full_name: 'ASC' };
break;
case 'name_desc':
order_by_li = { event_badge_full_name: 'DESC' };
break;
case 'created_asc':
order_by_li = { created_on: 'ASC' };
break;
case 'created_desc':
order_by_li = { created_on: 'DESC' };
break;
default:
order_by_li = { created_on: 'DESC' };
}
await events_func.download_export__event_exhibit_tracking({ const q_event_id: string = page.params.event_id ?? '';
const q_exhibit_id: string = exhibit_id ?? '';
const q_licensee_email: string | null =
params.licensee_email !== 'all'
? (params.licensee_email ?? '')
: null;
const results = await events_func.search__exhibit_tracking({
api_cfg: $ae_api, api_cfg: $ae_api,
exhibit_id: exhibit_id, event_id: q_event_id,
log_lvl: 1 event_exhibit_id: q_exhibit_id,
fulltext_search_qry_str: qry_str || null,
qry_external_person_id: q_licensee_email,
hidden: params.show_hidden ? 'all' : 'not_hidden',
order_by_li,
limit: 150
}); });
}
function toggle_main_tab() { if (current_search_id === last_search_id) {
if (active_tab === 'add') { const api_ids = results
set_active_tab('list'); .map((e: any) => String(e.id || e.event_exhibit_tracking_id))
previous_main_tab = 'list'; .filter(Boolean);
} else {
set_active_tab('add'); if (log_lvl)
previous_main_tab = 'add'; console.log(
`📦 [Trace] Lead Search #${current_search_id}: API revalidation found ${api_ids.length} items.`
);
untrack(() => {
tracking_id_li = api_ids;
$events_sess.leads.submit_status__search = 'done';
$events_sess.leads.last_refresh_time = new Date().toISOString();
});
}
} catch (error) {
if (current_search_id === last_search_id) {
console.error('Lead revalidation failed:', error);
untrack(() => {
$events_sess.leads.submit_status__search = 'error';
});
} }
} }
}
function toggle_manage_tab() { async function handle_export() {
if (active_tab === 'manage') { const exhibit_id = page.params.exhibit_id;
set_active_tab(previous_main_tab); if (!exhibit_id) return;
} else {
set_active_tab('manage'); await events_func.download_export__event_exhibit_tracking({
} api_cfg: $ae_api,
exhibit_id: exhibit_id,
log_lvl: 1
});
}
function toggle_main_tab() {
if (active_tab === 'add') {
set_active_tab('list');
previous_main_tab = 'list';
} else {
set_active_tab('add');
previous_main_tab = 'add';
} }
}
function toggle_manage_tab() {
if (active_tab === 'manage') {
set_active_tab(previous_main_tab);
} else {
set_active_tab('manage');
}
}
</script> </script>
<section <section
class="ae_events_leads_tracking_new h-full w-full flex flex-col items-center overflow-x-hidden" class="ae_events_leads_tracking_new flex h-full w-full flex-col items-center overflow-x-hidden">
>
<!-- Header --> <!-- Header -->
<header class="w-full bg-surface-100-900 border-b border-surface-500/20 px-4 py-2 sticky top-0 z-10 flex items-center justify-between gap-4 shadow-sm"> <header
<div class="flex flex-col min-w-0"> class="bg-surface-100-900 border-surface-500/20 sticky top-0 z-10 flex w-full items-center justify-between gap-4 border-b px-4 py-2 shadow-sm">
<h1 class="text-base sm:text-lg font-bold truncate leading-tight"> <div class="flex min-w-0 flex-col">
<h1 class="truncate text-base leading-tight font-bold sm:text-lg">
{$lq__exhibit_obj?.name ?? 'Exhibitor'} {$lq__exhibit_obj?.name ?? 'Exhibitor'}
</h1> </h1>
<p class="text-[10px] sm:text-xs opacity-60">Booth #{$lq__exhibit_obj?.code ?? '...'}</p> <p class="text-[10px] opacity-60 sm:text-xs">
Booth #{$lq__exhibit_obj?.code ?? '...'}
</p>
</div> </div>
<div class="flex items-center gap-1 sm:gap-2"> <div class="flex items-center gap-1 sm:gap-2">
@@ -404,9 +429,8 @@
<!-- Add Lead / Lead List Toggle --> <!-- Add Lead / Lead List Toggle -->
<button <button
type="button" type="button"
class="btn btn-sm preset-filled-primary font-bold shadow-sm px-2 sm:px-4" class="btn btn-sm preset-filled-primary px-2 font-bold shadow-sm sm:px-4"
onclick={toggle_main_tab} onclick={toggle_main_tab}>
>
{#if active_tab === 'add'} {#if active_tab === 'add'}
<ListIcon size="1.25em" class="sm:mr-2" /> <ListIcon size="1.25em" class="sm:mr-2" />
<span class="hidden sm:inline">Lead List</span> <span class="hidden sm:inline">Lead List</span>
@@ -420,12 +444,11 @@
{#if $ae_loc.show_leads_payment} {#if $ae_loc.show_leads_payment}
<button <button
type="button" type="button"
class="btn btn-sm transition-colors px-2 sm:px-3" class="btn btn-sm px-2 transition-colors sm:px-3"
class:preset-filled-success={active_tab === 'payment'} class:preset-filled-success={active_tab === 'payment'}
class:preset-outlined-success={active_tab !== 'payment'} class:preset-outlined-success={active_tab !== 'payment'}
onclick={() => set_active_tab('payment')} onclick={() => set_active_tab('payment')}
title="Payment & Upgrades" title="Payment & Upgrades">
>
<CreditCard size="1.25em" /> <CreditCard size="1.25em" />
</button> </button>
{/if} {/if}
@@ -433,67 +456,65 @@
<!-- Manage / Config --> <!-- Manage / Config -->
<button <button
type="button" type="button"
class="btn btn-sm transition-colors px-2 sm:px-3" class="btn btn-sm px-2 transition-colors sm:px-3"
class:preset-tonal-surface={active_tab === 'manage'} class:preset-tonal-surface={active_tab === 'manage'}
class:preset-outlined-surface={active_tab !== 'manage'} class:preset-outlined-surface={active_tab !== 'manage'}
onclick={toggle_manage_tab} onclick={toggle_manage_tab}
title="Manage Exhibit" title="Manage Exhibit">
>
<Settings size="1.25em" /> <Settings size="1.25em" />
</button> </button>
{/if} {/if}
</div> </div>
</header> </header>
<!-- Main Content Area - Stable Width --> <!-- Main Content Area - Stable Width -->
<div class="w-full flex-1 flex flex-col items-center"> <div class="flex w-full flex-1 flex-col items-center">
<div class="w-full px-4 sm:px-6 py-6 space-y-6"> <div class="w-full space-y-6 px-4 py-6 sm:px-6">
{#if !is_signed_in} {#if !is_signed_in}
<div class="w-full max-w-4xl mx-auto"> <div class="mx-auto w-full max-w-4xl">
<Tab_start /> <Tab_start />
</div> </div>
{:else if active_tab === 'add'} {:else if active_tab === 'add'}
<Tab_add exhibit_id={page.params.exhibit_id ?? ''} /> <Tab_add exhibit_id={page.params.exhibit_id ?? ''} />
{:else if active_tab === 'payment'} {:else if active_tab === 'payment'}
<div class="w-full max-w-4xl mx-auto"> <div class="mx-auto w-full max-w-4xl">
<Comp_exhibit_payment /> <Comp_exhibit_payment />
</div> </div>
{:else if active_tab === 'list'} {:else if active_tab === 'list'}
<div class="w-full flex flex-col space-y-6"> <div class="flex w-full flex-col space-y-6">
<div class="flex justify-between items-center px-2"> <div class="flex items-center justify-between px-2">
<h2 class="text-xl sm:text-2xl font-bold">Lead List</h2> <h2 class="text-xl font-bold sm:text-2xl">Lead List</h2>
{#if $lq__exhibit_obj?.leads_api_access === true} {#if $lq__exhibit_obj?.leads_api_access === true}
<button <button
type="button" type="button"
class="btn btn-sm preset-outlined-secondary" class="btn btn-sm preset-outlined-secondary"
onclick={handle_export} onclick={handle_export}>
>
<Download size="1.2em" class="mr-2" /> Export <Download size="1.2em" class="mr-2" /> Export
</button> </button>
{/if} {/if}
</div> </div>
<Comp_exhibit_tracking_search exhibit_id={page.params.exhibit_id ?? ''} /> <Comp_exhibit_tracking_search
exhibit_id={page.params.exhibit_id ?? ''} />
{#if $events_sess.leads.submit_status__search === 'searching' && tracking_id_li.length === 0} {#if $events_sess.leads.submit_status__search === 'searching' && tracking_id_li.length === 0}
<div <div
class="flex flex-col items-center justify-center p-10 opacity-50 text-center w-full" class="flex w-full flex-col items-center justify-center p-10 text-center opacity-50">
> <LoaderCircle
<LoaderCircle size="3em" class="animate-spin mb-4 mx-auto" /> size="3em"
class="mx-auto mb-4 animate-spin" />
<p class="text-xl">Searching leads...</p> <p class="text-xl">Searching leads...</p>
</div> </div>
{:else} {:else}
<Comp_exhibit_tracking_obj_li lq__event_exhibit_tracking_obj_li={filtered_lead_li} /> <Comp_exhibit_tracking_obj_li
lq__event_exhibit_tracking_obj_li={filtered_lead_li} />
{/if} {/if}
</div> </div>
{:else if active_tab === 'manage'} {:else if active_tab === 'manage'}
<div class="w-full max-w-4xl mx-auto"> <div class="mx-auto w-full max-w-4xl">
<Tab_manage /> <Tab_manage />
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
</section> </section>

View File

@@ -1,138 +1,186 @@
<script lang="ts"> <script lang="ts">
/** /**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_custom_questions.svelte * src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_custom_questions.svelte
* Exhibitor Custom Questions Editor - Handles leads_custom_questions_json. * Exhibitor Custom Questions Editor - Handles leads_custom_questions_json.
*/ */
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/ae_events_functions'; import { events_func } from '$lib/ae_events/ae_events_functions';
import { List, LoaderCircle, MessageSquare, Plus, Save, Trash2 } from '@lucide/svelte'; import {
interface Props { List,
exhibit_id: string; LoaderCircle,
event_id: string; MessageSquare,
custom_questions_json?: string; Plus,
} Save,
Trash2
} from '@lucide/svelte';
interface Props {
exhibit_id: string;
event_id: string;
custom_questions_json?: string;
}
let { exhibit_id, event_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 // Track the JSON as it was last saved so we can detect unsaved changes
let saved_json = $state('[]'); let saved_json = $state('[]');
$effect(() => { $effect(() => {
const incoming = custom_questions_json; // reactive dependency const incoming = custom_questions_json; // reactive dependency
try { try {
const parsed = JSON.parse(incoming || '[]'); const parsed = JSON.parse(incoming || '[]');
untrack(() => { untrack(() => {
if (Array.isArray(parsed) && parsed.length > 0) { if (Array.isArray(parsed) && parsed.length > 0) {
// Incoming prop has real content — load it (initial load or external update) // Incoming prop has real content — load it (initial load or external update)
questions = parsed; questions = parsed;
saved_json = JSON.stringify(parsed); saved_json = JSON.stringify(parsed);
} else if (questions.length === 0) { } else if (questions.length === 0) {
// Both empty — initialize state cleanly // Both empty — initialize state cleanly
saved_json = '[]'; saved_json = '[]';
} }
// If parsed is empty but we already have questions: the API response // If parsed is empty but we already have questions: the API response
// stripped leads_custom_questions_json from its return object and // stripped leads_custom_questions_json from its return object and
// overwrote Dexie with null. Keep our in-memory questions intact. // overwrote Dexie with null. Keep our in-memory questions intact.
}); });
} catch (e) { } catch (e) {
untrack(() => { if (questions.length === 0) { questions = []; saved_json = '[]'; } }); 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;
}
}
function add_question() {
questions.push({
// 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',
option_li: ['']
}); });
} }
});
function remove_question(index: number) { // True whenever the current questions differ from the last saved state
questions.splice(index, 1); let is_dirty = $derived(JSON.stringify(questions) !== saved_json);
}
// Helpers for option_li ↔ comma-string conversion in the UI async function save_questions() {
function get_options_str(q: any): string { if (!exhibit_id) return;
const li: string[] = Array.isArray(q.option_li) ? q.option_li : []; is_saving = true;
return li.filter((o: string) => o !== '').join(', '); 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;
} }
}
function set_options_str(q: any, val: string) { function add_question() {
// Always prepend empty string so the select has a blank default option questions.push({
q.option_li = ['', ...val.split(',').map((s: string) => s.trim()).filter(Boolean)]; // 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',
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> </script>
<div class="custom-questions-editor space-y-4"> <div class="custom-questions-editor space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-sm font-bold uppercase tracking-widest opacity-50">Lead Qualifiers</h3> <h3 class="text-sm font-bold tracking-widest uppercase opacity-50">
<span class="text-xs opacity-40 italic">Define questions for lead capture</span> Lead Qualifiers
</h3>
<span class="text-xs italic opacity-40"
>Define questions for lead capture</span>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
{#each questions as q, i (i)} {#each questions as q, i (i)}
<div class="card p-4 preset-tonal-surface border border-surface-500/10 space-y-3 animate-in fade-in slide-in-from-right-2"> <div
class="card preset-tonal-surface border-surface-500/10 animate-in fade-in slide-in-from-right-2 space-y-3 border p-4">
<!-- Question header row: number + delete (always visible for mobile) --> <!-- Question header row: number + delete (always visible for mobile) -->
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-[10px] uppercase font-black opacity-30 tracking-widest">Question {i + 1}</span> <span
class="text-[10px] font-black tracking-widest uppercase opacity-30"
>Question {i + 1}</span>
<button <button
class="btn btn-sm preset-outlined-error px-2 py-1" class="btn btn-sm preset-outlined-error px-2 py-1"
onclick={() => remove_question(i)} onclick={() => remove_question(i)}
title="Remove question" title="Remove question">
>
<Trash2 size="1em" /> <Trash2 size="1em" />
</button> </button>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Question / display label --> <!-- Question / display label -->
<div class="space-y-1"> <div class="space-y-1">
<label class="text-[10px] uppercase font-bold opacity-40" for="custom-q-{i}-question">Question / Label</label> <label
class="text-[10px] font-bold uppercase opacity-40"
for="custom-q-{i}-question">Question / Label</label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<MessageSquare size="1em" class="opacity-30 flex-none" /> <MessageSquare
<input id="custom-q-{i}-question" 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" /> size="1em"
class="flex-none opacity-30" />
<input
id="custom-q-{i}-question"
type="text"
bind:value={q.question}
placeholder="e.g. Purchasing Authority?"
class="border-surface-500/20 flex-1 border-b bg-transparent text-sm font-bold outline-none" />
</div> </div>
</div> </div>
<!-- Code / machine key --> <!-- Code / machine key -->
<div class="space-y-1"> <div class="space-y-1">
<label class="text-[10px] uppercase font-bold opacity-40" for="custom-q-{i}-code">Field Code <span class="normal-case font-normal opacity-70">(key in export)</span></label> <label
<input id="custom-q-{i}-code" 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" /> class="text-[10px] font-bold uppercase opacity-40"
for="custom-q-{i}-code"
>Field Code <span
class="font-normal normal-case opacity-70"
>(key in export)</span
></label>
<input
id="custom-q-{i}-code"
type="text"
bind:value={q.code}
placeholder="e.g. purchasing_authority"
class="border-surface-500/20 w-full border-b bg-transparent font-mono text-xs outline-none" />
</div> </div>
</div> </div>
<!-- Response Type --> <!-- Response Type -->
<div class="space-y-1"> <div class="space-y-1">
<label class="text-[10px] uppercase font-bold opacity-40" for="custom-q-{i}-type">Response Type</label> <label
<select id="custom-q-{i}-type" bind:value={q.type} class="select preset-tonal-surface text-xs p-1 rounded w-full"> class="text-[10px] font-bold uppercase opacity-40"
for="custom-q-{i}-type">Response Type</label>
<select
id="custom-q-{i}-type"
bind:value={q.type}
class="select preset-tonal-surface w-full rounded p-1 text-xs">
<option value="text">Short Text</option> <option value="text">Short Text</option>
<option value="textarea">Long Text</option> <option value="textarea">Long Text</option>
<option value="toggle">Yes / No (Toggle)</option> <option value="toggle">Yes / No (Toggle)</option>
@@ -141,18 +189,24 @@
</div> </div>
{#if q.type === 'option'} {#if q.type === 'option'}
<div class="space-y-1 pt-2 border-t border-surface-500/10"> <div class="border-surface-500/10 space-y-1 border-t pt-2">
<label class="text-[10px] uppercase font-bold opacity-40" for="custom-q-{i}-options">Options (comma-separated)</label> <label
class="text-[10px] font-bold uppercase opacity-40"
for="custom-q-{i}-options"
>Options (comma-separated)</label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<List size="1em" class="opacity-30 flex-none" /> <List size="1em" class="flex-none opacity-30" />
<input <input
id="custom-q-{i}-options" id="custom-q-{i}-options"
type="text" type="text"
value={get_options_str(q)} value={get_options_str(q)}
oninput={(e) => set_options_str(q, (e.target as HTMLInputElement).value)} oninput={(e) =>
set_options_str(
q,
(e.target as HTMLInputElement).value
)}
placeholder="Hot, Warm, Cold" placeholder="Hot, Warm, Cold"
class="bg-transparent border-b border-surface-500/20 outline-none flex-1 text-xs" class="border-surface-500/20 flex-1 border-b bg-transparent text-xs outline-none" />
/>
</div> </div>
</div> </div>
{/if} {/if}
@@ -160,7 +214,8 @@
{/each} {/each}
{#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="border-surface-500/20 rounded-xl border-2 border-dashed p-8 text-center 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 yet.</p> <p class="text-sm italic">No custom questions defined yet.</p>
</div> </div>
@@ -169,11 +224,15 @@
<!-- Unsaved changes warning --> <!-- Unsaved changes warning -->
{#if is_dirty} {#if is_dirty}
<p class="text-xs text-warning-500 font-bold text-center animate-pulse">Unsaved changes</p> <p class="text-warning-500 animate-pulse text-center text-xs font-bold">
Unsaved changes
</p>
{/if} {/if}
<div class="flex gap-2 pt-2"> <div class="flex gap-2 pt-2">
<button class="btn btn-sm preset-filled-secondary flex-1" onclick={add_question}> <button
class="btn btn-sm preset-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 <button
@@ -181,10 +240,9 @@
class:preset-filled-primary={is_dirty} class:preset-filled-primary={is_dirty}
class:preset-outlined-surface={!is_dirty} class:preset-outlined-surface={!is_dirty}
onclick={save_questions} onclick={save_questions}
disabled={is_saving || !is_dirty} 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="mr-2 animate-spin" />
{:else} {:else}
<Save size="1.2em" class="mr-2" /> <Save size="1.2em" class="mr-2" />
{/if} {/if}

View File

@@ -1,108 +1,126 @@
<script lang="ts"> <script lang="ts">
/** /**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_license_list.svelte * src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_license_list.svelte
* Exhibitor License Management - Handles parsing and editing event_exhibit.license_li_json * Exhibitor License Management - Handles parsing and editing event_exhibit.license_li_json
*/ */
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/ae_events_functions'; import { events_func } from '$lib/ae_events/ae_events_functions';
import { Key, LoaderCircle, Mail, Plus, Save, Trash2, User, Users } from '@lucide/svelte'; import {
interface Props { Key,
exhibit_id: string; LoaderCircle,
event_id: string; Mail,
license_li_json?: string; // Raw JSON string from DB Plus,
license_max?: number; Save,
} Trash2,
User,
Users
} from '@lucide/svelte';
interface Props {
exhibit_id: string;
event_id: string;
license_li_json?: string; // Raw JSON string from DB
license_max?: number;
}
let { exhibit_id, event_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([]);
let is_saving = $state(false); let is_saving = $state(false);
// Parse JSON into local state // Parse JSON into local state
$effect(() => { $effect(() => {
try { try {
const raw = license_li_json; const raw = license_li_json;
if (!raw) { if (!raw) {
untrack(() => local_license_li = []); untrack(() => (local_license_li = []));
return;
}
// Handle both string and pre-parsed array/object formats
let parsed = [];
if (Array.isArray(raw)) {
parsed = raw;
} else if (typeof raw === 'string') {
parsed = JSON.parse(raw || '[]');
}
untrack(() => {
local_license_li = Array.isArray(parsed) ? parsed : [];
});
} catch (e) {
console.error('Failed to parse license_li_json', e);
untrack(() => {
local_license_li = [];
});
}
});
async function save_licenses() {
if (!exhibit_id) return;
is_saving = true;
try {
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
}
});
} catch (e) {
console.error('Failed to save licenses', e);
} finally {
is_saving = false;
}
}
function add_license() {
if (local_license_li.length >= (license_max || 1)) {
alert(`Maximum licenses (${license_max}) reached.`);
return; return;
} }
local_license_li.push({
full_name: '', // Handle both string and pre-parsed array/object formats
email: '', let parsed = [];
passcode: Math.random().toString(36).substring(2, 8).toUpperCase() if (Array.isArray(raw)) {
parsed = raw;
} else if (typeof raw === 'string') {
parsed = JSON.parse(raw || '[]');
}
untrack(() => {
local_license_li = Array.isArray(parsed) ? parsed : [];
});
} catch (e) {
console.error('Failed to parse license_li_json', e);
untrack(() => {
local_license_li = [];
}); });
} }
});
function remove_license(index: number) { async function save_licenses() {
if (confirm('Remove this license? The user will lose access immediately.')) { if (!exhibit_id) return;
local_license_li.splice(index, 1); is_saving = true;
} try {
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
}
});
} catch (e) {
console.error('Failed to save licenses', e);
} finally {
is_saving = false;
} }
}
function add_license() {
if (local_license_li.length >= (license_max || 1)) {
alert(`Maximum licenses (${license_max}) reached.`);
return;
}
local_license_li.push({
full_name: '',
email: '',
passcode: Math.random().toString(36).substring(2, 8).toUpperCase()
});
}
function remove_license(index: number) {
if (
confirm('Remove this license? The user will lose access immediately.')
) {
local_license_li.splice(index, 1);
}
}
</script> </script>
<div class="exhibit-license-list space-y-4"> <div class="exhibit-license-list space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-sm font-bold uppercase tracking-widest opacity-50">Assigned Licenses</h3> <h3 class="text-sm font-bold tracking-widest uppercase opacity-50">
<span class="text-xs font-mono bg-surface-500/10 px-2 py-1 rounded"> Assigned Licenses
</h3>
<span class="bg-surface-500/10 rounded px-2 py-1 font-mono text-xs">
{local_license_li.length} / {license_max || 1} {local_license_li.length} / {license_max || 1}
</span> </span>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
{#each local_license_li as license, i (i)} {#each local_license_li as license, i (i)}
<div class="card p-4 preset-tonal-surface border border-surface-500/10 space-y-3 relative group animate-in fade-in slide-in-from-right-2"> <div
class="card preset-tonal-surface border-surface-500/10 group animate-in fade-in slide-in-from-right-2 relative space-y-3 border p-4">
<button <button
class="absolute top-2 right-2 p-2 text-error-500 opacity-0 group-hover:opacity-100 transition-opacity" class="text-error-500 absolute top-2 right-2 p-2 opacity-0 transition-opacity group-hover:opacity-100"
onclick={() => remove_license(i)} onclick={() => remove_license(i)}
title="Remove License" title="Remove License">
>
<Trash2 size="1.2em" /> <Trash2 size="1.2em" />
</button> </button>
@@ -113,8 +131,7 @@
type="text" type="text"
bind:value={license.full_name} bind:value={license.full_name}
placeholder="Full Name" placeholder="Full Name"
class="bg-transparent border-b border-surface-500/20 focus:border-primary-500 outline-none flex-1 text-sm font-bold" class="border-surface-500/20 focus:border-primary-500 flex-1 border-b bg-transparent text-sm font-bold outline-none" />
/>
</div> </div>
<!-- Email --> <!-- Email -->
@@ -124,8 +141,7 @@
type="email" type="email"
bind:value={license.email} bind:value={license.email}
placeholder="email@example.com" placeholder="email@example.com"
class="bg-transparent border-b border-surface-500/20 focus:border-primary-500 outline-none flex-1 text-sm" class="border-surface-500/20 focus:border-primary-500 flex-1 border-b bg-transparent text-sm outline-none" />
/>
</div> </div>
<!-- Passcode --> <!-- Passcode -->
@@ -135,14 +151,14 @@
type="text" type="text"
bind:value={license.passcode} bind:value={license.passcode}
placeholder="PASSCODE" placeholder="PASSCODE"
class="bg-transparent border-b border-surface-500/20 focus:border-primary-500 outline-none w-32 text-sm font-mono font-bold" class="border-surface-500/20 focus:border-primary-500 w-32 border-b bg-transparent font-mono text-sm font-bold outline-none" />
/>
</div> </div>
</div> </div>
{/each} {/each}
{#if local_license_li.length === 0} {#if local_license_li.length === 0}
<div class="p-8 text-center border-2 border-dashed border-surface-500/20 rounded-xl opacity-30"> <div
class="border-surface-500/20 rounded-xl border-2 border-dashed p-8 text-center opacity-30">
<Users size="2em" class="mx-auto mb-2" /> <Users size="2em" class="mx-auto mb-2" />
<p class="text-sm italic">No licenses assigned yet.</p> <p class="text-sm italic">No licenses assigned yet.</p>
</div> </div>
@@ -153,18 +169,16 @@
<button <button
class="btn btn-sm preset-filled-secondary flex-1" class="btn btn-sm preset-filled-secondary flex-1"
onclick={add_license} onclick={add_license}
disabled={local_license_li.length >= (license_max || 1)} disabled={local_license_li.length >= (license_max || 1)}>
>
<Plus size="1.2em" class="mr-2" /> Add Leads Licensee <Plus size="1.2em" class="mr-2" /> Add Leads Licensee
</button> </button>
<button <button
class="btn btn-sm preset-filled-primary flex-1" class="btn btn-sm preset-filled-primary flex-1"
onclick={save_licenses} onclick={save_licenses}
disabled={is_saving} disabled={is_saving}>
>
{#if is_saving} {#if is_saving}
<LoaderCircle size="1.2em" class="animate-spin mr-2" /> <LoaderCircle size="1.2em" class="mr-2 animate-spin" />
{:else} {:else}
<Save size="1.2em" class="mr-2" /> <Save size="1.2em" class="mr-2" />
{/if} {/if}

View File

@@ -1,11 +1,11 @@
<script lang="ts"> <script lang="ts">
/** /**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_payment.svelte * src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_payment.svelte
* Leads Payment Stub. * Leads Payment Stub.
*/ */
</script> </script>
<div class="exhibit-payment p-4 card"> <div class="exhibit-payment card p-4">
<h3 class="h3">Payment & Licensing</h3> <h3 class="h3">Payment & Licensing</h3>
<p>Placeholder for Stripe integration.</p> <p>Placeholder for Stripe integration.</p>
</div> </div>

View File

@@ -1,218 +1,255 @@
<script lang="ts"> <script lang="ts">
/** /**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_signin.svelte * src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_signin.svelte
* Exhibitor Sign-In Component - Handles both Shared Passcode and Licensed User login. * Exhibitor Sign-In Component - Handles both Shared Passcode and Licensed User login.
*/ */
import { page } from '$app/state'; import { page } from '$app/state';
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_loc } from '$lib/stores/ae_stores'; import { ae_loc } 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 { ArrowRight, CircleAlert, CircleCheck, Key, LoaderCircle, Lock, Mail, User } from '@lucide/svelte'; import {
import { untrack } from 'svelte'; ArrowRight,
CircleAlert,
CircleCheck,
Key,
LoaderCircle,
Lock,
Mail,
User
} from '@lucide/svelte';
import { untrack } from 'svelte';
const exhibit_id = $derived(page.params.exhibit_id ?? ''); const exhibit_id = $derived(page.params.exhibit_id ?? '');
let lq__exhibit_obj = $derived( let lq__exhibit_obj = $derived(
liveQuery(async () => { liveQuery(async () => {
if (!exhibit_id) return null; if (!exhibit_id) return null;
return await db_events.exhibit.get(exhibit_id); return await db_events.exhibit.get(exhibit_id);
}) })
); );
// Form State // Form State
let signin_mode = $state('passcode'); // 'passcode' or 'licensed' let signin_mode = $state('passcode'); // 'passcode' or 'licensed'
let passcode = $state(''); let passcode = $state('');
let email = $state(''); let email = $state('');
let user_passcode = $state(''); let user_passcode = $state('');
let status = $state('idle'); // 'idle', 'submitting', 'error', 'success' let status = $state('idle'); // 'idle', 'submitting', 'error', 'success'
let error_msg = $state(''); let error_msg = $state('');
// --- Auto-prefill for Trusted Users --- // --- Auto-prefill for Trusted Users ---
$effect(() => { $effect(() => {
if ($ae_loc.trusted_access && $lq__exhibit_obj?.staff_passcode) { if ($ae_loc.trusted_access && $lq__exhibit_obj?.staff_passcode) {
untrack(() => { untrack(() => {
if (!passcode) passcode = $lq__exhibit_obj?.staff_passcode ?? ''; if (!passcode) passcode = $lq__exhibit_obj?.staff_passcode ?? '';
}); });
}
});
async function handle_signin() {
if (!$lq__exhibit_obj) return;
status = 'submitting';
error_msg = '';
// Delay for better UX
await new Promise((r) => setTimeout(r, 800));
if (signin_mode === 'passcode') {
// 1. Shared Passcode logic
if (passcode === $lq__exhibit_obj.staff_passcode) {
// SUCCESS
complete_signin($lq__exhibit_obj.staff_passcode, 'shared');
} else {
status = 'error';
error_msg =
'Invalid shared passcode. Please check with your booth manager.';
} }
}); } else {
// 2. Licensed User logic
try {
// Determine raw JSON string
const raw_json = $lq__exhibit_obj?.license_li_json;
async function handle_signin() { // Parse if string, otherwise use empty array
if (!$lq__exhibit_obj) return; const licenses =
status = 'submitting'; typeof raw_json === 'string'
error_msg = ''; ? JSON.parse(raw_json || '[]')
: Array.isArray(raw_json)
? raw_json
: [];
// Delay for better UX const found = licenses.find(
await new Promise(r => setTimeout(r, 800)); (l: any) =>
l.email?.toLowerCase() === email.toLowerCase().trim()
);
if (signin_mode === 'passcode') { if (found && found.passcode === user_passcode) {
// 1. Shared Passcode logic
if (passcode === $lq__exhibit_obj.staff_passcode) {
// SUCCESS // SUCCESS
complete_signin($lq__exhibit_obj.staff_passcode, 'shared'); complete_signin(found.email, 'licensed');
} else { } else {
status = 'error'; status = 'error';
error_msg = 'Invalid shared passcode. Please check with your booth manager.'; error_msg = 'Invalid email or personal passcode.';
}
} else {
// 2. Licensed User logic
try {
// Determine raw JSON string
const raw_json = $lq__exhibit_obj?.license_li_json;
// Parse if string, otherwise use empty array
const licenses = typeof raw_json === 'string' ? JSON.parse(raw_json || '[]') : (Array.isArray(raw_json) ? raw_json : []);
const found = licenses.find((l: any) => l.email?.toLowerCase() === email.toLowerCase().trim());
if (found && found.passcode === user_passcode) {
// SUCCESS
complete_signin(found.email, 'licensed');
} else {
status = 'error';
error_msg = 'Invalid email or personal passcode.';
}
} catch (e) {
status = 'error';
error_msg = 'System error validating licenses.';
} }
} catch (e) {
status = 'error';
error_msg = 'System error validating licenses.';
} }
} }
}
function complete_signin(key: string, type: string) { function complete_signin(key: string, type: string) {
status = 'success'; status = 'success';
// Save to persistent store // Save to persistent store
if (!$events_loc.leads.auth_exhibit_kv) $events_loc.leads.auth_exhibit_kv = {}; if (!$events_loc.leads.auth_exhibit_kv)
$events_loc.leads.auth_exhibit_kv = {};
$events_loc.leads.auth_exhibit_kv[exhibit_id] = { $events_loc.leads.auth_exhibit_kv[exhibit_id] = {
key: key, key: key,
type: type, type: type,
updated_on: new Date().toISOString() updated_on: new Date().toISOString()
}; };
// Also update session passcode if shared mode // Also update session passcode if shared mode
if (type === 'shared') { if (type === 'shared') {
$events_sess.leads.entered_passcode = key; $events_sess.leads.entered_passcode = key;
}
// Trigger a reload or UI update if needed
// (The parent +page.svelte should reactively update is_signed_in)
} }
// Trigger a reload or UI update if needed
// (The parent +page.svelte should reactively update is_signed_in)
}
</script> </script>
<div class="exhibit-signin card p-6 preset-tonal-surface shadow-xl border border-surface-500/20 space-y-6"> <div
class="exhibit-signin card preset-tonal-surface border-surface-500/20 space-y-6 border p-6 shadow-xl">
<!-- Tab Toggle --> <!-- Tab Toggle -->
<div class="flex p-1 bg-surface-500/10 rounded-xl"> <div class="bg-surface-500/10 flex rounded-xl p-1">
<button <button
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg text-sm font-bold transition-all" class="flex flex-1 items-center justify-center gap-2 rounded-lg py-2 text-sm font-bold transition-all"
class:bg-surface-100-900={signin_mode === 'passcode'} class:bg-surface-100-900={signin_mode === 'passcode'}
class:shadow-sm={signin_mode === 'passcode'} class:shadow-sm={signin_mode === 'passcode'}
class:opacity-50={signin_mode !== 'passcode'} class:opacity-50={signin_mode !== 'passcode'}
onclick={() => signin_mode = 'passcode'} onclick={() => (signin_mode = 'passcode')}>
>
<Lock size="1.2em" /> Shared Code <Lock size="1.2em" /> Shared Code
</button> </button>
<button <button
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg text-sm font-bold transition-all" class="flex flex-1 items-center justify-center gap-2 rounded-lg py-2 text-sm font-bold transition-all"
class:bg-surface-100-900={signin_mode === 'licensed'} class:bg-surface-100-900={signin_mode === 'licensed'}
class:shadow-sm={signin_mode === 'licensed'} class:shadow-sm={signin_mode === 'licensed'}
class:opacity-50={signin_mode !== 'licensed'} class:opacity-50={signin_mode !== 'licensed'}
onclick={() => signin_mode = 'licensed'} onclick={() => (signin_mode = 'licensed')}>
>
<User size="1.2em" /> Licensed User <User size="1.2em" /> Licensed User
</button> </button>
</div> </div>
<!-- Forms --> <!-- Forms -->
<form onsubmit={(e) => { e.preventDefault(); handle_signin(); }} class="space-y-4"> <form
onsubmit={(e) => {
e.preventDefault();
handle_signin();
}}
class="space-y-4">
{#if signin_mode === 'passcode'} {#if signin_mode === 'passcode'}
<div class="space-y-2 animate-in fade-in slide-in-from-left-2"> <div class="animate-in fade-in slide-in-from-left-2 space-y-2">
<label class="label"> <label class="label">
<span class="text-[10px] uppercase font-bold opacity-50 ml-1 tracking-widest">Booth Passcode</span> <span
<div class="input-group input-group-divider grid-cols-[auto_1fr] preset-tonal-surface rounded-xl overflow-hidden border border-surface-500/20"> class="ml-1 text-[10px] font-bold tracking-widest uppercase opacity-50"
>Booth Passcode</span>
<div
class="input-group input-group-divider preset-tonal-surface border-surface-500/20 grid-cols-[auto_1fr] overflow-hidden rounded-xl border">
<div class="input-group-shim"><Key size="1.2em" /></div> <div class="input-group-shim"><Key size="1.2em" /></div>
<input <input
type="text" type="text"
bind:value={passcode} bind:value={passcode}
placeholder="Enter shared code..." placeholder="Enter shared code..."
class="bg-transparent font-mono tracking-[0.3em] font-bold text-center" class="bg-transparent text-center font-mono font-bold tracking-[0.3em]"
autocomplete="off" autocomplete="off" />
/>
</div> </div>
</label> </label>
</div> </div>
{:else} {:else}
<div class="space-y-4 animate-in fade-in slide-in-from-right-2"> <div class="animate-in fade-in slide-in-from-right-2 space-y-4">
<label class="label"> <label class="label">
<span class="text-[10px] uppercase font-bold opacity-50 ml-1 tracking-widest">Email Address</span> <span
<div class="input-group input-group-divider grid-cols-[auto_1fr] preset-tonal-surface rounded-xl overflow-hidden border border-surface-500/20"> class="ml-1 text-[10px] font-bold tracking-widest uppercase opacity-50"
<div class="input-group-shim"><Mail size="1.2em" /></div> >Email Address</span>
<div
class="input-group input-group-divider preset-tonal-surface border-surface-500/20 grid-cols-[auto_1fr] overflow-hidden rounded-xl border">
<div class="input-group-shim">
<Mail size="1.2em" />
</div>
<input <input
type="email" type="email"
bind:value={email} bind:value={email}
placeholder="your@email.com" placeholder="your@email.com"
class="bg-transparent" class="bg-transparent" />
/>
</div> </div>
</label> </label>
<label class="label"> <label class="label">
<span class="text-[10px] uppercase font-bold opacity-50 ml-1 tracking-widest">Personal Passcode</span> <span
<div class="input-group input-group-divider grid-cols-[auto_1fr] preset-tonal-surface rounded-xl overflow-hidden border border-surface-500/20"> class="ml-1 text-[10px] font-bold tracking-widest uppercase opacity-50"
>Personal Passcode</span>
<div
class="input-group input-group-divider preset-tonal-surface border-surface-500/20 grid-cols-[auto_1fr] overflow-hidden rounded-xl border">
<div class="input-group-shim"><Key size="1.2em" /></div> <div class="input-group-shim"><Key size="1.2em" /></div>
<input <input
type="text" type="text"
bind:value={user_passcode} bind:value={user_passcode}
placeholder="Your code..." placeholder="Your code..."
class="bg-transparent font-mono font-bold" class="bg-transparent font-mono font-bold"
autocomplete="off" autocomplete="off" />
/>
</div> </div>
</label> </label>
</div> </div>
{/if} {/if}
{#if status === 'error'} {#if status === 'error'}
<div class="p-3 rounded-lg preset-tonal-error flex items-start gap-3 animate-shake"> <div
<CircleAlert size="1.2em" class="shrink-0 mt-0.5" /> class="preset-tonal-error animate-shake flex items-start gap-3 rounded-lg p-3">
<p class="text-xs font-bold leading-tight">{error_msg}</p> <CircleAlert size="1.2em" class="mt-0.5 shrink-0" />
<p class="text-xs leading-tight font-bold">{error_msg}</p>
</div> </div>
{/if} {/if}
<button <button
type="submit" type="submit"
class="btn btn-lg preset-filled-primary w-full font-bold shadow-lg shadow-primary-500/20 group" class="btn btn-lg preset-filled-primary shadow-primary-500/20 group w-full font-bold shadow-lg"
disabled={status === 'submitting'} disabled={status === 'submitting'}>
>
{#if status === 'submitting'} {#if status === 'submitting'}
<LoaderCircle size="1.5em" class="animate-spin mr-2" /> <LoaderCircle size="1.5em" class="mr-2 animate-spin" />
Signing In... Signing In...
{:else if status === 'success'} {:else if status === 'success'}
<CircleCheck size="1.5em" class="mr-2" /> <CircleCheck size="1.5em" class="mr-2" />
Welcome! Welcome!
{:else} {:else}
Get Started <ArrowRight size="1.2em" class="ml-2 group-hover:translate-x-1 transition-transform" /> Get Started <ArrowRight
size="1.2em"
class="ml-2 transition-transform group-hover:translate-x-1" />
{/if} {/if}
</button> </button>
</form> </form>
<p class="text-[10px] text-center opacity-40 italic"> <p class="text-center text-[10px] italic opacity-40">
Check your welcome email or ask your booth manager for login details. Check your welcome email or ask your booth manager for login details.
</p> </p>
</div> </div>
<style lang="postcss"> <style lang="postcss">
/* Shake animation for errors */ /* Shake animation for errors */
@keyframes shake { @keyframes shake {
0%, 100% { transform: translateX(0); } 0%,
25% { transform: translateX(-4px); } 100% {
75% { transform: translateX(4px); } transform: translateX(0);
} }
.animate-shake { 25% {
animation: shake 0.2s ease-in-out 0s 2; transform: translateX(-4px);
} }
75% {
transform: translateX(4px);
}
}
.animate-shake {
animation: shake 0.2s ease-in-out 0s 2;
}
</style> </style>

View File

@@ -1,74 +1,95 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
lq__event_exhibit_tracking_obj_li: any; lq__event_exhibit_tracking_obj_li: any;
log_lvl?: number; log_lvl?: number;
}
let { lq__event_exhibit_tracking_obj_li, log_lvl = 0 }: Props = $props();
import {
ChevronRight,
Clock,
FileText,
LoaderCircle,
Mail,
MapPin,
User
} from '@lucide/svelte';
import { ae_util } from '$lib/ae_utils/ae_utils';
import { page } from '$app/state';
// Full ISO datetime for tooltip (hover title)
function format_date_full(date_str: string) {
if (!date_str) return '';
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) {
let { lq__event_exhibit_tracking_obj_li, log_lvl = 0 }: Props = $props(); const rem_hr = Math.round(hr - days * 24);
const day_label = `~${days} day${days > 1 ? 's' : ''}`;
import { ChevronRight, Clock, FileText, LoaderCircle, Mail, MapPin, User } from '@lucide/svelte'; return rem_hr > 1
import { ae_util } from '$lib/ae_utils/ae_utils'; ? `${day_label} ${rem_hr} hrs ago`
import { page } from '$app/state'; : `${day_label} ago`;
// Full ISO datetime for tooltip (hover title)
function format_date_full(date_str: string) {
if (!date_str) return '';
return new Date(date_str).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
} }
const weeks = Math.round(days / 7);
// Fuzzy relative time — "~10 min ago", "~1.5 hrs ago", "~2 days ago" if (days < 28) {
// Exact for first few minutes, increasingly coarse after that. return `~${weeks} week${weeks > 1 ? 's' : ''} ago`;
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`;
} }
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>
<div class="ae_comp__exhibit_tracking_obj_li w-full px-2 sm:px-4"> <div class="ae_comp__exhibit_tracking_obj_li w-full px-2 sm:px-4">
{#if !lq__event_exhibit_tracking_obj_li} {#if !lq__event_exhibit_tracking_obj_li}
<div class="flex justify-center p-10"> <div class="flex justify-center p-10">
<LoaderCircle size="2rem" class="animate-spin opacity-20" aria-hidden="true" /> <LoaderCircle
size="2rem"
class="animate-spin opacity-20"
aria-hidden="true" />
</div> </div>
{:else if lq__event_exhibit_tracking_obj_li.length === 0} {:else if lq__event_exhibit_tracking_obj_li.length === 0}
<div class="card p-8 text-center preset-tonal-surface"> <div class="card preset-tonal-surface p-8 text-center">
<p class="text-xl opacity-50">No leads found yet.</p> <p class="text-xl opacity-50">No leads found yet.</p>
<p class="text-sm opacity-50 mt-2"> <p class="mt-2 text-sm opacity-50">
Start scanning badges to collect leads! Start scanning badges to collect leads!
</p> </p>
</div> </div>
{:else} {:else}
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between items-center px-2"> <div class="flex items-center justify-between px-2">
<span class="text-sm font-semibold opacity-50"> <span class="text-sm font-semibold opacity-50">
{lq__event_exhibit_tracking_obj_li.length} Leads Collected {lq__event_exhibit_tracking_obj_li.length} Leads Collected
</span> </span>
@@ -78,8 +99,7 @@
{#each lq__event_exhibit_tracking_obj_li as event_tracking_obj (event_tracking_obj.event_exhibit_tracking_id)} {#each lq__event_exhibit_tracking_obj_li as event_tracking_obj (event_tracking_obj.event_exhibit_tracking_id)}
<a <a
href={`/events/${page.params.event_id}/leads/exhibit/${event_tracking_obj.event_exhibit_id}/lead/${event_tracking_obj.event_exhibit_tracking_id}`} href={`/events/${page.params.event_id}/leads/exhibit/${event_tracking_obj.event_exhibit_id}/lead/${event_tracking_obj.event_exhibit_tracking_id}`}
class="card card-hover p-4 preset-tonal-surface border-l-4 border-primary-500 flex flex-col md:flex-row gap-4 items-start md:items-center" class="card card-hover preset-tonal-surface border-primary-500 flex flex-col items-start gap-4 border-l-4 p-4 md:flex-row md:items-center">
>
<div class="flex-grow space-y-1"> <div class="flex-grow space-y-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<User size="1.25em" class="text-primary-500" /> <User size="1.25em" class="text-primary-500" />
@@ -91,8 +111,7 @@
</div> </div>
<div <div
class="flex flex-wrap gap-x-4 gap-y-1 text-sm opacity-70" class="flex flex-wrap gap-x-4 gap-y-1 text-sm opacity-70">
>
{#if event_tracking_obj.event_badge_email} {#if event_tracking_obj.event_badge_email}
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<Mail size="1em" /> <Mail size="1em" />
@@ -107,27 +126,31 @@
{/if} {/if}
<div <div
class="flex items-center gap-1" class="flex items-center gap-1"
title={format_date_full(event_tracking_obj.created_on)} title={format_date_full(
> event_tracking_obj.created_on
)}>
<Clock size="1em" /> <Clock size="1em" />
{fuzzy_time_ago(event_tracking_obj.created_on)} {fuzzy_time_ago(
event_tracking_obj.created_on
)}
</div> </div>
</div> </div>
{#if event_tracking_obj.exhibitor_notes} {#if event_tracking_obj.exhibitor_notes}
<div <div
class="mt-2 p-2 bg-surface-100-900 rounded text-sm italic border-l-2 border-surface-300-700" class="bg-surface-100-900 border-surface-300-700 mt-2 rounded border-l-2 p-2 text-sm italic">
> <FileText size="1em" class="mr-1 inline" />
<FileText size="1em" class="inline mr-1" />
{ae_util.shorten_string({ {ae_util.shorten_string({
string: ae_util.strip_html(event_tracking_obj.exhibitor_notes), string: ae_util.strip_html(
event_tracking_obj.exhibitor_notes
),
max_length: 100 max_length: 100
})} })}
</div> </div>
{/if} {/if}
</div> </div>
<div class="flex-shrink-0 self-center hidden md:block"> <div class="hidden flex-shrink-0 self-center md:block">
<ChevronRight size="2em" class="opacity-20" /> <ChevronRight size="2em" class="opacity-20" />
</div> </div>
</a> </a>

View File

@@ -1,96 +1,106 @@
<script lang="ts"> <script lang="ts">
interface Props { interface Props {
exhibit_id: string; exhibit_id: string;
log_lvl?: number; log_lvl?: number;
} }
let { exhibit_id, log_lvl = 0 }: Props = $props(); let { exhibit_id, log_lvl = 0 }: Props = $props();
// *** Import other supporting libraries // *** Import other supporting libraries
import { Eye, EyeOff, Library, LoaderCircle, RemoveFormatting, Search } from '@lucide/svelte'; import {
import { untrack } from 'svelte'; Eye,
import { ae_loc } from '$lib/stores/ae_stores'; EyeOff,
import { events_loc, events_sess } from '$lib/stores/ae_events_stores'; Library,
import { liveQuery } from 'dexie'; LoaderCircle,
import { db_events } from '$lib/ae_events/db_events'; RemoveFormatting,
import { onMount } from 'svelte'; Search
} from '@lucide/svelte';
import { untrack } from 'svelte';
import { ae_loc } from '$lib/stores/ae_stores';
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import { onMount } from 'svelte';
let exhibit_obj: any = $state(null); let exhibit_obj: any = $state(null);
onMount(() => { onMount(() => {
const observable = liveQuery(async () => { const observable = liveQuery(async () => {
if (!exhibit_id) return null; if (!exhibit_id) return null;
// 1. Try primary lookup // 1. Try primary lookup
let res = await db_events.exhibit.get(exhibit_id); let res = await db_events.exhibit.get(exhibit_id);
// 2. Fallback to random ID index // 2. Fallback to random ID index
if (!res) { if (!res) {
res = await db_events.exhibit.where('event_exhibit_id_random').equals(exhibit_id).first(); res = await db_events.exhibit
} .where('event_exhibit_id_random')
return res; .equals(exhibit_id)
}); .first();
const subscription = observable.subscribe((value) => { }
exhibit_obj = value; return res;
});
return () => subscription.unsubscribe();
}); });
const subscription = observable.subscribe((value) => {
exhibit_obj = value;
});
return () => subscription.unsubscribe();
});
// Reactive list derived from the exhibit state (Licensed Exhibit Leads Users) // Reactive list derived from the exhibit state (Licensed Exhibit Leads Users)
let licensee_li = $derived.by(() => { let licensee_li = $derived.by(() => {
try { try {
const raw = exhibit_obj?.license_li_json; const raw = exhibit_obj?.license_li_json;
if (!raw) return []; if (!raw) return [];
// If it's already an array, return it. If it's a string, parse it. // If it's already an array, return it. If it's a string, parse it.
if (Array.isArray(raw)) return raw; if (Array.isArray(raw)) return raw;
if (typeof raw === 'string') return JSON.parse(raw || '[]'); if (typeof raw === 'string') return JSON.parse(raw || '[]');
return []; return [];
} catch (e) { } catch (e) {
console.error('Failed to parse licensee_li_json', e); console.error('Failed to parse licensee_li_json', e);
return []; return [];
}
});
// Default selection logic: Aether Admins go to "all", Licensees go to "my"
$effect(() => {
// Wait for object to load and check if initialized
if (!exhibit_obj) return;
untrack(() => {
if (
$events_loc.leads.tracking__qry__licensee_email === 'all' &&
!$ae_loc.administrator_access
) {
$events_loc.leads.tracking__qry__licensee_email = 'my';
} }
}); });
});
// Default selection logic: Aether Admins go to "all", Licensees go to "my" function handle_search_trigger() {
$effect(() => { if ($events_loc.leads.tracking__search_version === undefined) {
// Wait for object to load and check if initialized $events_loc.leads.tracking__search_version = 0;
if (!exhibit_obj) return;
untrack(() => {
if ($events_loc.leads.tracking__qry__licensee_email === 'all' && !$ae_loc.administrator_access) {
$events_loc.leads.tracking__qry__licensee_email = 'my';
}
});
});
function handle_search_trigger() {
if ($events_loc.leads.tracking__search_version === undefined) {
$events_loc.leads.tracking__search_version = 0;
}
$events_loc.leads.tracking__search_version++;
} }
$events_loc.leads.tracking__search_version++;
}
function prevent_default<T extends Event>(fn: (event: T) => void) { function prevent_default<T extends Event>(fn: (event: T) => void) {
return function (event: T) { return function (event: T) {
event.preventDefault(); event.preventDefault();
fn(event); fn(event);
}; };
} }
</script> </script>
<div <div
class="ae_group filters_and_search flex flex-col items-center justify-center gap-2 w-full" class="ae_group filters_and_search flex w-full flex-col items-center justify-center gap-2">
>
<form <form
onsubmit={prevent_default(() => { onsubmit={prevent_default(() => {
handle_search_trigger(); handle_search_trigger();
})} })}
autocomplete="off" autocomplete="off"
class="search_form flex flex-row flex-wrap gap-1 items-center justify-center w-full px-2 py-2 preset-tonal-primary rounded-lg shadow-sm" class="search_form preset-tonal-primary flex w-full flex-row flex-wrap items-center justify-center gap-1 rounded-lg px-2 py-2 shadow-sm">
>
<div <div
class="flex flex-col md:flex-row items-center justify-center gap-1 grow" class="flex grow flex-col items-center justify-center gap-1 md:flex-row">
>
<input <input
type="search" type="search"
placeholder="Search leads (name, email, notes)..." placeholder="Search leads (name, email, notes)..."
@@ -98,20 +108,18 @@
bind:value={$events_loc.leads.tracking__qry__search_text} bind:value={$events_loc.leads.tracking__qry__search_text}
autocomplete="off" autocomplete="off"
data-lpignore="true" data-lpignore="true"
class="input text-lg font-mono grow transition-all" class="input grow font-mono text-lg transition-all"
onkeyup={(e) => { onkeyup={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handle_search_trigger(); handle_search_trigger();
} }
}} }}
title="Search by name, email or notes. Press Enter." title="Search by name, email or notes. Press Enter." />
/>
<select <select
bind:value={$events_loc.leads.tracking__qry__sort_order} bind:value={$events_loc.leads.tracking__qry__sort_order}
onchange={handle_search_trigger} onchange={handle_search_trigger}
class="select select-sm text-xs px-1 max-w-fit" class="select select-sm max-w-fit px-1 text-xs">
>
<option value="created_desc">Newest First</option> <option value="created_desc">Newest First</option>
<option value="created_asc">Oldest First</option> <option value="created_asc">Oldest First</option>
<option value="name_asc">Name ASC</option> <option value="name_asc">Name ASC</option>
@@ -121,8 +129,7 @@
<select <select
bind:value={$events_loc.leads.tracking__qry__licensee_email} bind:value={$events_loc.leads.tracking__qry__licensee_email}
onchange={handle_search_trigger} onchange={handle_search_trigger}
class="select select-sm text-xs px-1 max-w-fit" class="select select-sm max-w-fit px-1 text-xs">
>
<option value="all">All Leads</option> <option value="all">All Leads</option>
{#if !$ae_loc.administrator_access} {#if !$ae_loc.administrator_access}
<option value="my">My Leads</option> <option value="my">My Leads</option>
@@ -136,10 +143,9 @@
<div class="flex flex-row items-center justify-center gap-1"> <div class="flex flex-row items-center justify-center gap-1">
<button <button
type="submit" type="submit"
class="btn btn-lg preset-tonal-primary border border-primary-500 hover:preset-tonal-primary text-2xl font-bold w-48 transition-all" class="btn btn-lg preset-tonal-primary border-primary-500 hover:preset-tonal-primary w-48 border text-2xl font-bold transition-all">
>
{#if $events_sess.leads.submit_status__search === 'searching'} {#if $events_sess.leads.submit_status__search === 'searching'}
<LoaderCircle class="animate-spin mx-1" /> <LoaderCircle class="mx-1 animate-spin" />
{:else} {:else}
<Search class="mx-1" /> <Search class="mx-1" />
{/if} {/if}
@@ -153,9 +159,8 @@
$events_loc.leads.tracking__qry__search_text = ''; $events_loc.leads.tracking__qry__search_text = '';
handle_search_trigger(); handle_search_trigger();
}} }}
class="btn btn-sm text-xs preset-outlined-tertiary-100-900 hover:preset-filled-tertiary-100-900 transition-all" class="btn btn-sm preset-outlined-tertiary-100-900 hover:preset-filled-tertiary-100-900 text-xs transition-all"
title="Clear search query" title="Clear search query">
>
<RemoveFormatting size="1.25em" /> <RemoveFormatting size="1.25em" />
<span class="hidden md:inline"> Clear </span> <span class="hidden md:inline"> Clear </span>
</button> </button>
@@ -163,20 +168,20 @@
</form> </form>
<div <div
class="flex flex-row flex-wrap items-center justify-center gap-2 opacity-70 hover:opacity-100 transition-all" class="flex flex-row flex-wrap items-center justify-center gap-2 opacity-70 transition-all hover:opacity-100">
>
<!-- Show/Hide hidden records toggle — always visible --> <!-- Show/Hide hidden records toggle — always visible -->
<button <button
type="button" type="button"
class="flex items-center gap-1 cursor-pointer px-2 py-1 rounded-token text-xs font-semibold transition-colors" class="rounded-token flex cursor-pointer items-center gap-1 px-2 py-1 text-xs font-semibold transition-colors"
class:preset-tonal-warning={$events_loc.leads.show_hidden} class:preset-tonal-warning={$events_loc.leads.show_hidden}
class:preset-tonal-surface={!$events_loc.leads.show_hidden} class:preset-tonal-surface={!$events_loc.leads.show_hidden}
onclick={() => { onclick={() => {
$events_loc.leads.show_hidden = !$events_loc.leads.show_hidden; $events_loc.leads.show_hidden = !$events_loc.leads.show_hidden;
handle_search_trigger(); handle_search_trigger();
}} }}
title={$events_loc.leads.show_hidden ? 'Showing hidden leads — click to hide them' : 'Hidden leads excluded — click to show all'} title={$events_loc.leads.show_hidden
> ? 'Showing hidden leads — click to hide them'
: 'Hidden leads excluded — click to show all'}>
{#if $events_loc.leads.show_hidden} {#if $events_loc.leads.show_hidden}
<Eye size="1em" /> <Eye size="1em" />
<span>Showing Hidden</span> <span>Showing Hidden</span>
@@ -188,15 +193,13 @@
{#if $ae_loc.edit_mode} {#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="bg-surface-200-800 rounded-token flex cursor-pointer items-center gap-1 px-2 py-1 text-xs font-semibold">
>
<span> Remote First </span> <span> Remote First </span>
<input <input
type="checkbox" type="checkbox"
bind:checked={$events_loc.leads.tracking__qry__remote_first} bind:checked={$events_loc.leads.tracking__qry__remote_first}
onchange={handle_search_trigger} onchange={handle_search_trigger}
class="checkbox checkbox-sm" class="checkbox checkbox-sm" />
/>
</label> </label>
{/if} {/if}
</div> </div>

View File

@@ -1,146 +1,167 @@
<script lang="ts"> <script lang="ts">
/** /**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_manual_search.svelte * src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_manual_search.svelte
* Manual Attendee Search for adding leads. * Manual Attendee Search for adding leads.
*/ */
import { page } from '$app/state'; import { page } from '$app/state';
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, ae_loc } from '$lib/stores/ae_stores'; import { ae_api, ae_loc } 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/ae_events_functions'; import { events_func } from '$lib/ae_events/ae_events_functions';
import { Eye, LoaderCircle, Search, ShieldOff, UserPlus } from '@lucide/svelte'; import { Eye, LoaderCircle, Search, ShieldOff, UserPlus } from '@lucide/svelte';
import type { ae_EventBadge } from '$lib/types/ae_types'; import type { ae_EventBadge } from '$lib/types/ae_types';
import { ae_util } from '$lib/ae_utils/ae_utils'; import { ae_util } from '$lib/ae_utils/ae_utils';
interface Props { interface Props {
exhibit_id: string; exhibit_id: string;
on_lead_added?: (badge: ae_EventBadge) => void; on_lead_added?: (badge: ae_EventBadge) => void;
}
let { exhibit_id, on_lead_added }: Props = $props();
// Track existing leads to prevent duplicates in UI
let existing_leads_map = $derived(
liveQuery(async () => {
const leads = await db_events.exhibit_tracking
.where('event_exhibit_id')
.equals(exhibit_id)
.toArray();
// Map badge_id -> tracking_id
const map = new Map();
leads.forEach((l) => {
const b_id =
l.event_badge_id_random || l.event_badge_id?.toString();
if (b_id)
map.set(
b_id,
l.event_exhibit_tracking_id_random ||
l.event_exhibit_tracking_id?.toString()
);
});
return map;
})
);
let search_query = $state('');
let results: ae_EventBadge[] = $state([]);
let searching = $state(false);
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,
event_id: page.params.event_id || '',
fulltext_search_qry_str: search_query,
limit: 20
});
results = Array.isArray(search_results) ? search_results : [];
} catch (e) {
console.error('Badge search failed', e);
} finally {
searching = false;
}
}
async function add_as_lead(badge: ae_EventBadge) {
// Use id or id_random — whichever is populated from search results
// NO LONGER USE "_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;
} }
let { exhibit_id, on_lead_added }: Props = $props(); // Gate: attendee must have opted in to lead tracking (allow_tracking must be explicitly true).
// Defensive guard — the UI already hides the Add button for blocked badges,
// Track existing leads to prevent duplicates in UI // but this prevents any direct/programmatic calls from bypassing the check.
let existing_leads_map = $derived( if (badge.allow_tracking !== true) {
liveQuery(async () => { console.warn(
const leads = await db_events.exhibit_tracking '[add_as_lead] blocked — allow_tracking is not true for badge',
.where('event_exhibit_id') badge_id
.equals(exhibit_id) );
.toArray(); return;
// Map badge_id -> tracking_id
const map = new Map();
leads.forEach(l => {
const b_id = l.event_badge_id_random || l.event_badge_id?.toString();
if (b_id) map.set(b_id, l.event_exhibit_tracking_id_random || l.event_exhibit_tracking_id?.toString());
});
return map;
})
);
let search_query = $state('');
let results: ae_EventBadge[] = $state([]);
let searching = $state(false);
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,
event_id: page.params.event_id || '',
fulltext_search_qry_str: search_query,
limit: 20
});
results = Array.isArray(search_results) ? search_results : [];
} catch (e) {
console.error('Badge search failed', e);
} finally {
searching = false;
}
} }
async function add_as_lead(badge: ae_EventBadge) { adding_id = badge_id;
// Use id or id_random — whichever is populated from search results add_error_id = '';
// NO LONGER USE "_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;
}
// Gate: attendee must have opted in to lead tracking (allow_tracking must be explicitly true). const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
// Defensive guard — the UI already hides the Add button for blocked badges, const user_email =
// but this prevents any direct/programmatic calls from bypassing the check. kv?.type === 'licensed' && kv.key
if (badge.allow_tracking !== true) { ? kv.key
console.warn('[add_as_lead] blocked — allow_tracking is not true for badge', badge_id); : kv?.type === 'shared'
return; ? 'shared_passcode'
} : $ae_loc.access_type || 'anonymous';
adding_id = badge_id; try {
add_error_id = ''; 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
});
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id]; if (result) {
const user_email = kv?.type === 'licensed' && kv.key ? kv.key // Surface a View Details link next to this result row
: kv?.type === 'shared' ? 'shared_passcode' last_added_badge_id = badge_id;
: $ae_loc.access_type || 'anonymous'; last_added_tracking_id =
result.event_exhibit_tracking_id_random ||
try { String(result.event_exhibit_tracking_id || '');
const result = await events_func.create_ae_obj__exhibit_tracking({ if (on_lead_added) on_lead_added(badge);
api_cfg: $ae_api, } else {
exhibit_id: exhibit_id, // API returned null/false — surface a visible error on this row
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) {
add_error_id = badge_id; add_error_id = badge_id;
console.error('[add_as_lead] Failed to add lead', e); console.warn(
} finally { '[add_as_lead] API returned null for badge_id',
adding_id = ''; badge_id
);
} }
} catch (e) {
add_error_id = badge_id;
console.error('[add_as_lead] Failed to add lead', e);
} finally {
adding_id = '';
} }
}
</script> </script>
<div class="lead-manual-search space-y-4 w-full"> <div class="lead-manual-search w-full space-y-4">
<form <form
class="search_form flex flex-row flex-wrap gap-1 items-center justify-center w-full px-2 py-2 preset-tonal-primary rounded-lg shadow-sm" class="search_form preset-tonal-primary flex w-full flex-row flex-wrap items-center justify-center gap-1 rounded-lg px-2 py-2 shadow-sm"
onsubmit={(e) => { e.preventDefault(); handle_search(); }} onsubmit={(e) => {
> e.preventDefault();
<div class="flex flex-col md:flex-row items-center justify-center gap-1 grow"> handle_search();
}}>
<div
class="flex grow flex-col items-center justify-center gap-1 md:flex-row">
<input <input
type="search" type="search"
bind:value={search_query} bind:value={search_query}
placeholder="Attendee name, email, or badge ID..." placeholder="Attendee name, email, or badge ID..."
class="input text-lg font-mono grow transition-all w-full" class="input w-full grow font-mono text-lg transition-all" />
/>
</div> </div>
<div class="flex flex-row items-center justify-center gap-1"> <div class="flex flex-row items-center justify-center gap-1">
<button <button
type="submit" type="submit"
class="btn btn-lg preset-tonal-primary border border-primary-500 hover:preset-tonal-primary text-2xl font-bold w-48 transition-all" class="btn btn-lg preset-tonal-primary border-primary-500 hover:preset-tonal-primary w-48 border text-2xl font-bold transition-all"
disabled={searching} disabled={searching}>
>
{#if searching} {#if searching}
<LoaderCircle class="animate-spin mx-1" size="1.2em" /> <LoaderCircle class="mx-1 animate-spin" size="1.2em" />
{:else} {:else}
<Search class="mx-1" size="1.2em" /> <Search class="mx-1" size="1.2em" />
{/if} {/if}
@@ -150,33 +171,46 @@
</form> </form>
{#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 max-h-[50vh] space-y-2 overflow-y-auto pr-2">
{#each results as badge (badge.event_badge_id_random ?? badge.event_badge_id)} {#each results as badge (badge.event_badge_id_random ?? badge.event_badge_id)}
{@const badge_id = badge.event_badge_id_random || badge.event_badge_id} {@const badge_id =
{@const existing_id = $existing_leads_map?.get(badge_id) ?? (last_added_badge_id === badge_id ? last_added_tracking_id : '')} badge.event_badge_id_random || badge.event_badge_id}
<div class="card p-3 flex justify-between items-center preset-tonal-surface shadow-sm"> {@const existing_id =
$existing_leads_map?.get(badge_id) ??
(last_added_badge_id === badge_id
? last_added_tracking_id
: '')}
<div
class="card preset-tonal-surface flex items-center justify-between p-3 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_id} {#if existing_id}
<a <a
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${existing_id}`} href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${existing_id}`}
class="btn btn-sm preset-filled-secondary" class="btn btn-sm preset-filled-secondary">
>
<Eye size="1em" class="mr-1" /> <Eye size="1em" class="mr-1" />
View View
</a> </a>
{:else if badge.allow_tracking !== true} {:else if badge.allow_tracking !== true}
<!-- Attendee has not opted in to tracking — cannot add as lead --> <!-- Attendee has not opted in to tracking — cannot add as lead -->
<span class="flex items-center gap-1 text-xs text-warning-500 font-bold opacity-70" title="This attendee has not opted in to exhibitor lead tracking."> <span
class="text-warning-500 flex items-center gap-1 text-xs font-bold opacity-70"
title="This attendee has not opted in to exhibitor lead tracking.">
<ShieldOff size="1em" /> <ShieldOff size="1em" />
Opt-Out Opt-Out
</span> </span>
{:else if add_error_id === badge_id} {:else if add_error_id === badge_id}
<span class="text-xs text-error-500 font-bold">Add failed — retry? <span class="text-error-500 text-xs font-bold"
<button type="button" class="btn btn-sm preset-outlined-error ml-1" onclick={() => add_as_lead(badge)}> >Add failed — retry?
<button
type="button"
class="btn btn-sm preset-outlined-error ml-1"
onclick={() => add_as_lead(badge)}>
Retry Retry
</button> </button>
</span> </span>
@@ -185,8 +219,7 @@
type="button" type="button"
class="btn btn-sm preset-filled-success" class="btn btn-sm preset-filled-success"
disabled={!!adding_id && adding_id === badge_id} disabled={!!adding_id && adding_id === badge_id}
onclick={() => add_as_lead(badge)} onclick={() => add_as_lead(badge)}>
>
{#if adding_id === badge_id} {#if adding_id === badge_id}
<LoaderCircle class="animate-spin" size="1em" /> <LoaderCircle class="animate-spin" size="1em" />
{:else} {:else}
@@ -199,6 +232,8 @@
{/each} {/each}
</div> </div>
{:else if !searching && search_query} {:else if !searching && search_query}
<p class="text-center opacity-50 py-4 italic">No attendees found matching "{search_query}"</p> <p class="py-4 text-center italic opacity-50">
No attendees found matching "{search_query}"
</p>
{/if} {/if}
</div> </div>

View File

@@ -1,275 +1,319 @@
<script lang="ts"> <script lang="ts">
/** /**
* 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): * scan_qualify modes (controlled by parent ae_tab__add):
* - 'rapid': after add → auto-reset scanner (scan next person fast) * - 'rapid': after add → auto-reset scanner (scan next person fast)
* - 'qualify': after add → navigate to lead detail (fill notes/qualifiers) * - '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 { 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, ae_loc } from '$lib/stores/ae_stores'; import { ae_api, ae_loc } 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/ae_events_functions'; import { events_func } from '$lib/ae_events/ae_events_functions';
import Element_qr_scanner from '$lib/elements/element_qr_scanner.svelte'; import Element_qr_scanner from '$lib/elements/element_qr_scanner.svelte';
import { ae_util } from '$lib/ae_utils/ae_utils'; import { ae_util } from '$lib/ae_utils/ae_utils';
import { Camera, CircleAlert, CircleCheck, Eye, LoaderCircle, RefreshCw, RotateCcw, ShieldOff, X } from '@lucide/svelte'; import {
import { SvelteMap } from 'svelte/reactivity'; Camera,
import type { ae_EventBadge } from '$lib/types/ae_types'; CircleAlert,
CircleCheck,
Eye,
LoaderCircle,
RefreshCw,
RotateCcw,
ShieldOff,
X
} from '@lucide/svelte';
import { SvelteMap } from 'svelte/reactivity';
import type { ae_EventBadge } from '$lib/types/ae_types';
interface Props { interface Props {
exhibit_id: string; exhibit_id: string;
scan_qualify?: 'rapid' | 'qualify' | 'auto'; scan_qualify?: 'rapid' | 'qualify' | 'auto';
on_lead_added?: (badge: ae_EventBadge) => void; on_lead_added?: (badge: ae_EventBadge) => void;
} }
let { exhibit_id, scan_qualify = 'rapid', on_lead_added }: Props = $props(); let { exhibit_id, scan_qualify = 'rapid', on_lead_added }: Props = $props();
// Track existing leads to detect duplicates and previously-removed records. // Track existing leads to detect duplicates and previously-removed records.
// Value includes tracking_id AND enabled status so we can offer re-activation. // Value includes tracking_id AND enabled status so we can offer re-activation.
let existing_leads_map = $derived( let existing_leads_map = $derived(
liveQuery(async () => { liveQuery(async () => {
const leads = await db_events.exhibit_tracking const leads = await db_events.exhibit_tracking
.where('event_exhibit_id') .where('event_exhibit_id')
.equals(exhibit_id) .equals(exhibit_id)
.toArray(); .toArray();
const map = new SvelteMap<string, { tracking_id: string; enabled: boolean }>(); const map = new SvelteMap<
leads.forEach(l => { string,
const b_id = l.event_badge_id?.toString(); { tracking_id: string; enabled: boolean }
if (b_id) map.set(b_id, { >();
leads.forEach((l) => {
const b_id = l.event_badge_id?.toString();
if (b_id)
map.set(b_id, {
tracking_id: l.event_exhibit_tracking_id?.toString() ?? '', tracking_id: l.event_exhibit_tracking_id?.toString() ?? '',
// enable stored as 1/0 or true/false — !! normalises all falsy values // enable stored as 1/0 or true/false — !! normalises all falsy values
enabled: !!l.enable enabled: !!l.enable
}); });
});
return map;
})
);
let start_qr_scanner = $state(true);
let scanning_status = $state('idle'); // idle, scanning, found, adding, success, error, already_added, tracking_blocked, reenable
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: {
detail: { result: string; entry_method: string };
}) {
const qr_result = event.detail.result;
const obj = ae_util.process_data_string(qr_result);
if (obj && obj.type === 'event_badge' && obj.id) {
start_qr_scanner = false;
// Check if already exists (enabled or disabled/removed)
if ($existing_leads_map?.has(obj.id)) {
const existing = $existing_leads_map.get(obj.id)!;
existing_tracking_id = existing.tracking_id;
// Distinguish: active lead vs previously-removed lead
scanning_status = existing.enabled ? 'already_added' : 'reenable';
} else {
scanning_status = 'found';
}
// Load full badge info (needed for allow_tracking check and display)
try {
found_badge = await events_func.load_ae_obj_id__event_badge({
api_cfg: $ae_api,
event_badge_id: obj.id,
log_lvl: 1
}); });
return map;
})
);
let start_qr_scanner = $state(true); // Gate: attendee must have opted in to lead tracking.
let scanning_status = $state('idle'); // idle, scanning, found, adding, success, error, already_added, tracking_blocked, reenable // allow_tracking must be explicitly true — default on badges is false (opt-in model).
let found_badge: ae_EventBadge | null = $state(null); // Only applies to the 'found' state; already-captured badges are left as-is.
let existing_tracking_id = $state(''); if (
let new_tracking_id = $state(''); // ID of the lead just created — used for "View Details" link scanning_status === 'found' &&
let error_msg = $state(''); found_badge?.allow_tracking !== true
) {
async function handle_qr_scan_result(event: { detail: { result: string; entry_method: string } }) { scanning_status = 'tracking_blocked';
const qr_result = event.detail.result;
const obj = ae_util.process_data_string(qr_result);
if (obj && obj.type === 'event_badge' && obj.id) {
start_qr_scanner = false;
// Check if already exists (enabled or disabled/removed)
if ($existing_leads_map?.has(obj.id)) {
const existing = $existing_leads_map.get(obj.id)!;
existing_tracking_id = existing.tracking_id;
// Distinguish: active lead vs previously-removed lead
scanning_status = existing.enabled ? 'already_added' : 'reenable';
} else {
scanning_status = 'found';
} }
// Load full badge info (needed for allow_tracking check and display) // Auto mode: skip the confirm card — add immediately if tracking is allowed.
if (scanning_status === 'found' && scan_qualify === 'auto') {
await confirm_add_lead();
}
} catch (e) {
console.error('Failed to load badge info', e);
}
} else {
scanning_status = 'error';
error_msg = 'Invalid QR code. Please scan an Event Badge.';
}
}
async function confirm_add_lead(dest: 'scan_next' | 'view_lead' = 'scan_next') {
if (!found_badge || !found_badge.event_badge_id) {
console.warn(
'[leads] Guard failed — event_badge_id missing. found_badge:',
found_badge
);
return;
}
scanning_status = 'adding';
// Resolve who is capturing this lead:
// licensed exhibit user → their email (kv.key)
// shared passcode → 'shared_passcode' label (don't store the actual passcode)
// Aether user (no kv) → access_type string ('trusted', 'manager', 'super', etc.)
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const user_email =
kv?.type === 'licensed' && kv.key
? kv.key
: kv?.type === 'shared'
? 'shared_passcode'
: $ae_loc.access_type || 'anonymous';
try {
const result = await events_func.create_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: exhibit_id,
event_badge_id: found_badge.event_badge_id,
external_person_id: user_email,
group: user_email
});
if (result) {
// Capture the new tracking ID so we can link to it
new_tracking_id = String(result.event_exhibit_tracking_id || '');
scanning_status = 'success';
if (on_lead_added) on_lead_added(found_badge);
if (dest === 'view_lead' && new_tracking_id) {
// View Lead: 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 {
// Scan Next / auto mode: auto-reset after 2 seconds to scan the next person
setTimeout(reset_scanner, 2000);
}
} else {
// API returned false/null — could be a duplicate of a previously-removed record
// that isn't in the local IDB cache (e.g. after IDB clear or on a second device).
// Search the API for a disabled tracking record for this badge before surfacing an error.
try { try {
found_badge = await events_func.load_ae_obj_id__event_badge({ const disabled_li = await events_func.search__exhibit_tracking({
api_cfg: $ae_api, api_cfg: $ae_api,
event_badge_id: obj.id, event_id: page.params.event_id,
event_exhibit_id: exhibit_id,
qry_badge_id: found_badge.event_badge_id,
enabled: 'not_enabled',
log_lvl: 1 log_lvl: 1
}); });
if (disabled_li.length > 0) {
// Gate: attendee must have opted in to lead tracking. // Found a disabled record — offer to re-activate instead of showing an error
// allow_tracking must be explicitly true — default on badges is false (opt-in model). existing_tracking_id = String(
// Only applies to the 'found' state; already-captured badges are left as-is. disabled_li[0].event_exhibit_tracking_id || ''
if (scanning_status === 'found' && found_badge?.allow_tracking !== true) { );
scanning_status = 'tracking_blocked'; scanning_status = 'reenable';
} else {
scanning_status = 'error';
error_msg =
'Failed to add lead. Check your connection and try again.';
} }
} catch {
scanning_status = 'error';
error_msg =
'Failed to add lead. Check your connection and try again.';
}
}
} catch {
scanning_status = 'error';
error_msg = 'Failed to add lead. They might already be added.';
}
}
// Auto mode: skip the confirm card — add immediately if tracking is allowed. async function confirm_reenable_lead(
if (scanning_status === 'found' && scan_qualify === 'auto') { dest: 'scan_next' | 'view_lead' = 'scan_next'
await confirm_add_lead(); ) {
} // Re-activate a lead that was previously removed (enable=false).
} catch (e) { // existing_tracking_id is already set from the map or the API fallback search.
console.error('Failed to load badge info', e); if (!existing_tracking_id) return;
scanning_status = 'adding';
try {
const result = await events_func.update_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id,
exhibit_tracking_id: existing_tracking_id,
data: { enable: true }
});
if (result) {
new_tracking_id = existing_tracking_id;
scanning_status = 'success';
if (on_lead_added && found_badge) on_lead_added(found_badge);
if (dest === 'view_lead' && new_tracking_id) {
goto(
`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`
);
} else {
setTimeout(reset_scanner, 2000);
} }
} else { } else {
scanning_status = 'error';
error_msg = 'Invalid QR code. Please scan an Event Badge.';
}
}
async function confirm_add_lead(dest: 'scan_next' | 'view_lead' = 'scan_next') {
if (!found_badge || !found_badge.event_badge_id) {
console.warn('[leads] Guard failed — event_badge_id missing. found_badge:', found_badge);
return;
}
scanning_status = 'adding';
// Resolve who is capturing this lead:
// licensed exhibit user → their email (kv.key)
// shared passcode → 'shared_passcode' label (don't store the actual passcode)
// Aether user (no kv) → access_type string ('trusted', 'manager', 'super', etc.)
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const user_email = kv?.type === 'licensed' && kv.key ? kv.key
: kv?.type === 'shared' ? 'shared_passcode'
: $ae_loc.access_type || 'anonymous';
try {
const result = await events_func.create_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: exhibit_id,
event_badge_id: found_badge.event_badge_id,
external_person_id: user_email,
group: user_email
});
if (result) {
// Capture the new tracking ID so we can link to it
new_tracking_id = String(result.event_exhibit_tracking_id || '');
scanning_status = 'success';
if (on_lead_added) on_lead_added(found_badge);
if (dest === 'view_lead' && new_tracking_id) {
// View Lead: 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 {
// Scan Next / auto mode: auto-reset after 2 seconds to scan the next person
setTimeout(reset_scanner, 2000);
}
} else {
// API returned false/null — could be a duplicate of a previously-removed record
// that isn't in the local IDB cache (e.g. after IDB clear or on a second device).
// Search the API for a disabled tracking record for this badge before surfacing an error.
try {
const disabled_li = await events_func.search__exhibit_tracking({
api_cfg: $ae_api,
event_id: page.params.event_id,
event_exhibit_id: exhibit_id,
qry_badge_id: found_badge.event_badge_id,
enabled: 'not_enabled',
log_lvl: 1
});
if (disabled_li.length > 0) {
// Found a disabled record — offer to re-activate instead of showing an error
existing_tracking_id = String(disabled_li[0].event_exhibit_tracking_id || '');
scanning_status = 'reenable';
} else {
scanning_status = 'error';
error_msg = 'Failed to add lead. Check your connection and try again.';
}
} catch {
scanning_status = 'error';
error_msg = 'Failed to add lead. Check your connection and try again.';
}
}
} catch {
scanning_status = 'error';
error_msg = 'Failed to add lead. They might already be added.';
}
}
async function confirm_reenable_lead(dest: 'scan_next' | 'view_lead' = 'scan_next') {
// Re-activate a lead that was previously removed (enable=false).
// existing_tracking_id is already set from the map or the API fallback search.
if (!existing_tracking_id) return;
scanning_status = 'adding';
try {
const result = await events_func.update_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id,
exhibit_tracking_id: existing_tracking_id,
data: { enable: true }
});
if (result) {
new_tracking_id = existing_tracking_id;
scanning_status = 'success';
if (on_lead_added && found_badge) on_lead_added(found_badge);
if (dest === 'view_lead' && new_tracking_id) {
goto(`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`);
} else {
setTimeout(reset_scanner, 2000);
}
} else {
scanning_status = 'error';
error_msg = 'Failed to restore lead. Please try again.';
}
} catch {
scanning_status = 'error'; scanning_status = 'error';
error_msg = 'Failed to restore lead. Please try again.'; error_msg = 'Failed to restore lead. Please try again.';
} }
} catch {
scanning_status = 'error';
error_msg = 'Failed to restore lead. Please try again.';
} }
}
function reset_scanner() { function reset_scanner() {
scanning_status = 'idle'; scanning_status = 'idle';
found_badge = null; found_badge = null;
new_tracking_id = ''; 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-100 justify-center"> <div
class="lead-qr-scanner flex min-h-100 w-full flex-col items-center justify-center space-y-4">
{#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="border-surface-500/20 bg-surface-900/10 relative mx-auto aspect-square w-full max-w-sm overflow-hidden rounded-xl border-4 shadow-xl">
<Element_qr_scanner <Element_qr_scanner
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
<div class="absolute inset-0 pointer-events-none border-2 border-primary-500/50 m-8 sm:m-12 rounded-lg animate-pulse"></div> class="border-primary-500/50 pointer-events-none absolute inset-0 m-8 animate-pulse rounded-lg border-2 sm:m-12">
</div>
</div> </div>
<p class="text-center opacity-70 italic text-sm">Point camera at the badge QR code</p> <p class="text-center text-sm italic opacity-70">
Point camera at the badge QR code
</p>
{:else if scanning_status === 'tracking_blocked'} {:else if scanning_status === 'tracking_blocked'}
<div class="card p-6 w-full max-w-md space-y-4 preset-tonal-warning shadow-xl border-2 border-warning-500 animate-in zoom-in"> <div
<div class="text-center space-y-2"> class="card preset-tonal-warning border-warning-500 animate-in zoom-in w-full max-w-md space-y-4 border-2 p-6 shadow-xl">
<ShieldOff size="3em" class="mx-auto text-warning-500" /> <div class="space-y-2 text-center">
<ShieldOff size="3em" class="text-warning-500 mx-auto" />
<h3 class="h3 font-bold">Tracking Opt-Out</h3> <h3 class="h3 font-bold">Tracking Opt-Out</h3>
<p class="text-xl font-bold">{found_badge?.full_name || 'Attendee'}</p> <p class="text-xl font-bold">
<p class="opacity-70 text-sm"> {found_badge?.full_name || 'Attendee'}
</p>
<p class="text-sm opacity-70">
This attendee has opted out of exhibitor lead scanning. This attendee has opted out of exhibitor lead scanning.
</p> </p>
</div> </div>
<button <button
type="button" type="button"
class="btn w-full preset-filled-warning font-bold" class="btn preset-filled-warning w-full font-bold"
onclick={reset_scanner} onclick={reset_scanner}>
>
<Camera size="1.2em" /> <Camera size="1.2em" />
Scan Next Scan Next
</button> </button>
</div> </div>
{:else if scanning_status === 'reenable'} {:else if scanning_status === 'reenable'}
<div class="card p-6 w-full max-w-md space-y-4 preset-tonal-warning shadow-xl border-2 border-warning-500 animate-in zoom-in"> <div
<div class="text-center space-y-2"> class="card preset-tonal-warning border-warning-500 animate-in zoom-in w-full max-w-md space-y-4 border-2 p-6 shadow-xl">
<RotateCcw size="3em" class="mx-auto text-warning-500" /> <div class="space-y-2 text-center">
<RotateCcw size="3em" class="text-warning-500 mx-auto" />
<h3 class="h3 font-bold">Previously Removed</h3> <h3 class="h3 font-bold">Previously Removed</h3>
<p class="text-xl font-bold">{found_badge?.full_name || 'Attendee'}</p> <p class="text-xl font-bold">
<p class="opacity-70 text-sm">This lead was removed. Re-activate to restore their record including any saved notes and responses.</p> {found_badge?.full_name || 'Attendee'}
</p>
<p class="text-sm opacity-70">
This lead was removed. Re-activate to restore their record
including any saved notes and responses.
</p>
</div> </div>
<!-- Two-button confirm — same pattern as the main confirm card --> <!-- Two-button confirm — same pattern as the main confirm card -->
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
class="rounded-xl py-5 font-bold text-sm flex flex-col items-center justify-center gap-2 bg-warning-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer" class="bg-warning-500 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl py-5 text-sm font-bold text-white shadow-md transition-all hover:brightness-110 active:brightness-90"
onclick={() => confirm_reenable_lead('scan_next')} onclick={() => confirm_reenable_lead('scan_next')}>
>
<Camera size="1.5em" /> <Camera size="1.5em" />
Restore &amp; Scan Next Restore &amp; Scan Next
</button> </button>
<button <button
type="button" type="button"
class="rounded-xl py-5 font-bold text-sm flex flex-col items-center justify-center gap-2 bg-secondary-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer" class="bg-secondary-500 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl py-5 text-sm font-bold text-white shadow-md transition-all hover:brightness-110 active:brightness-90"
onclick={() => confirm_reenable_lead('view_lead')} onclick={() => confirm_reenable_lead('view_lead')}>
>
<Eye size="1.5em" /> <Eye size="1.5em" />
Restore &amp; View Lead Restore &amp; View Lead
</button> </button>
@@ -277,27 +321,29 @@
<button <button
type="button" type="button"
class="w-full rounded-lg py-3 text-sm font-medium flex items-center justify-center gap-2 border border-surface-500/40 hover:bg-surface-200-800 transition-colors cursor-pointer opacity-70" class="border-surface-500/40 hover:bg-surface-200-800 flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border py-3 text-sm font-medium opacity-70 transition-colors"
onclick={reset_scanner} onclick={reset_scanner}>
>
<X size="1em" /> <X size="1em" />
Cancel / Scan Again Cancel / Scan Again
</button> </button>
</div> </div>
{:else if scanning_status === 'already_added'} {:else if scanning_status === 'already_added'}
<div class="card p-6 w-full max-w-md space-y-4 preset-tonal-secondary shadow-xl border-2 border-secondary-500 animate-in zoom-in"> <div
<div class="text-center space-y-2"> class="card preset-tonal-secondary border-secondary-500 animate-in zoom-in w-full max-w-md space-y-4 border-2 p-6 shadow-xl">
<CircleCheck size="3em" class="mx-auto text-secondary-500" /> <div class="space-y-2 text-center">
<CircleCheck size="3em" class="text-secondary-500 mx-auto" />
<h3 class="h3 font-bold">Already Captured</h3> <h3 class="h3 font-bold">Already Captured</h3>
<p class="text-xl font-bold">{found_badge?.full_name || 'Attendee'}</p> <p class="text-xl font-bold">
<p class="opacity-70 text-sm">This attendee is already in your leads list.</p> {found_badge?.full_name || 'Attendee'}
</p>
<p class="text-sm opacity-70">
This attendee is already in your leads list.
</p>
</div> </div>
<a <a
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${existing_tracking_id}`} href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${existing_tracking_id}`}
class="btn btn-xl w-full preset-filled-secondary font-bold py-6" class="btn btn-xl preset-filled-secondary w-full py-6 font-bold">
>
<Eye size="1.5em" class="mr-2" /> <Eye size="1.5em" class="mr-2" />
View Lead Details View Lead Details
</a> </a>
@@ -305,46 +351,49 @@
<button <button
type="button" type="button"
class="btn btn-sm w-full opacity-50" class="btn btn-sm w-full opacity-50"
onclick={reset_scanner} onclick={reset_scanner}>
>
<Camera size="1em" /> <Camera size="1em" />
Scan Next Scan Next
</button> </button>
</div> </div>
{:else if scanning_status === 'found' || scanning_status === 'adding'} {:else if scanning_status === 'found' || scanning_status === 'adding'}
<!-- bg-surface-50-900: canonical card face token — near-white (light) / deep slate (dark). <!-- bg-surface-50-900: canonical card face token — near-white (light) / deep slate (dark).
Explicit rather than preset-tonal-* so primary/surface buttons have guaranteed contrast. --> Explicit rather than preset-tonal-* so primary/surface buttons have guaranteed contrast. -->
<!-- Buttons use direct Tailwind tokens, not btn/preset-*, because the Skeleton <!-- Buttons use direct Tailwind tokens, not btn/preset-*, because the Skeleton
preset-filled chain resolves to transparent in this card context. --> preset-filled chain resolves to transparent in this card context. -->
<div class="card p-6 w-full max-w-md space-y-4 bg-surface-50-900 shadow-xl border-2 border-primary-500"> <div
class="card bg-surface-50-900 border-primary-500 w-full max-w-md space-y-4 border-2 p-6 shadow-xl">
<div class="text-center"> <div class="text-center">
<h3 class="h3 font-bold">{found_badge?.full_name || 'Badge Found'}</h3> <h3 class="h3 font-bold">
{found_badge?.full_name || 'Badge Found'}
</h3>
<p class="opacity-70">{found_badge?.affiliations || ''}</p> <p class="opacity-70">{found_badge?.affiliations || ''}</p>
</div> </div>
{#if scan_qualify === 'auto' || scanning_status === 'adding'} {#if scan_qualify === 'auto' || scanning_status === 'adding'}
<!-- Auto mode or mid-add: no buttons — adding happens automatically / in progress --> <!-- Auto mode or mid-add: no buttons — adding happens automatically / in progress -->
<div class="flex items-center justify-center gap-3 py-3 opacity-70"> <div
class="flex items-center justify-center gap-3 py-3 opacity-70">
<LoaderCircle class="animate-spin" size="1.5em" /> <LoaderCircle class="animate-spin" size="1.5em" />
<span class="font-bold">{scan_qualify === 'auto' ? 'Auto-adding...' : 'Adding Lead...'}</span> <span class="font-bold"
>{scan_qualify === 'auto'
? 'Auto-adding...'
: 'Adding Lead...'}</span>
</div> </div>
{:else} {:else}
<!-- Two-button confirm: staff chooses what to do after adding this lead --> <!-- Two-button confirm: staff chooses what to do after adding this lead -->
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
class="rounded-xl py-5 font-bold text-sm flex flex-col items-center justify-center gap-2 bg-primary-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer" class="bg-primary-500 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl py-5 text-sm font-bold text-white shadow-md transition-all hover:brightness-110 active:brightness-90"
onclick={() => confirm_add_lead('scan_next')} onclick={() => confirm_add_lead('scan_next')}>
>
<Camera size="1.5em" /> <Camera size="1.5em" />
Add &amp; Scan Next Add &amp; Scan Next
</button> </button>
<button <button
type="button" type="button"
class="rounded-xl py-5 font-bold text-sm flex flex-col items-center justify-center gap-2 bg-secondary-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer" class="bg-secondary-500 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl py-5 text-sm font-bold text-white shadow-md transition-all hover:brightness-110 active:brightness-90"
onclick={() => confirm_add_lead('view_lead')} onclick={() => confirm_add_lead('view_lead')}>
>
<Eye size="1.5em" /> <Eye size="1.5em" />
Add &amp; View Lead Add &amp; View Lead
</button> </button>
@@ -352,19 +401,20 @@
<button <button
type="button" type="button"
class="w-full rounded-lg py-3 text-sm font-medium flex items-center justify-center gap-2 border border-surface-500/40 hover:bg-surface-200-800 transition-colors cursor-pointer opacity-70" class="border-surface-500/40 hover:bg-surface-200-800 flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border py-3 text-sm font-medium opacity-70 transition-colors"
onclick={reset_scanner} onclick={reset_scanner}>
>
<X size="1em" /> <X size="1em" />
Cancel / Scan Again Cancel / Scan Again
</button> </button>
{/if} {/if}
</div> </div>
{:else if scanning_status === 'success'} {:else if scanning_status === 'success'}
<div class="card w-full max-w-md flex flex-col items-center space-y-4 preset-tonal-success shadow-xl overflow-hidden"> <div
<div class="p-10 w-full flex flex-col items-center space-y-4"> class="card preset-tonal-success flex w-full max-w-md flex-col items-center space-y-4 overflow-hidden shadow-xl">
<CircleCheck size="4em" class="text-success-500 animate-bounce" /> <div class="flex w-full flex-col items-center space-y-4 p-10">
<CircleCheck
size="4em"
class="text-success-500 animate-bounce" />
<div class="text-center"> <div class="text-center">
<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>
@@ -373,26 +423,31 @@
{#if new_tracking_id} {#if new_tracking_id}
<a <a
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`} href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`}
class="btn btn-sm preset-outlined-surface w-full" class="btn btn-sm preset-outlined-surface w-full">
>
<Eye size="1em" /> <Eye size="1em" />
View Details View Details
</a> </a>
{/if} {/if}
<p class="text-xs opacity-50 uppercase tracking-widest">Scanning next in 2 seconds...</p> <p class="text-xs tracking-widest uppercase opacity-50">
Scanning next in 2 seconds...
</p>
</div> </div>
<!-- Countdown bar: pure CSS animation depletes over 2s (matching the setTimeout reset delay). <!-- Countdown bar: pure CSS animation depletes over 2s (matching the setTimeout reset delay).
Gives the operator a clear visual cue that the scanner is about to reset. --> Gives the operator a clear visual cue that the scanner is about to reset. -->
<div class="w-full h-1.5 bg-success-200/40"> <div class="bg-success-200/40 h-1.5 w-full">
<div class="h-full bg-success-500 scanner-reset-countdown"></div> <div class="bg-success-500 scanner-reset-countdown h-full">
</div>
</div> </div>
</div> </div>
{:else if scanning_status === 'error'} {:else if scanning_status === 'error'}
<div class="card p-6 w-full max-w-md flex flex-col items-center space-y-4 preset-tonal-error"> <div
class="card preset-tonal-error flex w-full max-w-md flex-col items-center space-y-4 p-6">
<CircleAlert size="3em" class="text-error-500" /> <CircleAlert size="3em" class="text-error-500" />
<p class="text-center font-bold">{error_msg}</p> <p class="text-center font-bold">{error_msg}</p>
<button type="button" class="btn btn-sm preset-filled-error" onclick={reset_scanner}> <button
type="button"
class="btn btn-sm preset-filled-error"
onclick={reset_scanner}>
<RefreshCw size="1em" /> <RefreshCw size="1em" />
Try Again Try Again
</button> </button>
@@ -401,13 +456,17 @@
</div> </div>
<style> <style>
/* Countdown bar for the rapid-mode success card. /* Countdown bar for the rapid-mode success card.
Must match the 2000ms setTimeout in reset_scanner(). */ Must match the 2000ms setTimeout in reset_scanner(). */
.scanner-reset-countdown { .scanner-reset-countdown {
animation: scanner-reset-countdown 2s linear forwards; animation: scanner-reset-countdown 2s linear forwards;
}
@keyframes scanner-reset-countdown {
from {
width: 100%;
} }
@keyframes scanner-reset-countdown { to {
from { width: 100%; } width: 0%;
to { width: 0%; }
} }
}
</style> </style>

View File

@@ -1,298 +1,353 @@
<script lang="ts"> <script lang="ts">
/** /**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte * src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte
* Multi-badge batch QR scanner. * Multi-badge batch QR scanner.
* *
* Uses the native BarcodeDetector API (Chrome/Edge/Safari 17+) to detect multiple * Uses the native BarcodeDetector API (Chrome/Edge/Safari 17+) to detect multiple
* QR codes in a single camera frame. Staff lay 14 badges flat in view, tap * QR codes in a single camera frame. Staff lay 14 badges flat in view, tap
* "Capture Batch", and a grid of confirm cards appears — one per detected badge. * "Capture Batch", and a grid of confirm cards appears — one per detected badge.
* Cards can be individually added, skipped, or dismissed; "Add All" handles the batch. * Cards can be individually added, skipped, or dismissed; "Add All" handles the batch.
* *
* Hard cap: 8 badges per batch (browser/camera support varies; 4 is the practical ask). * Hard cap: 8 badges per batch (browser/camera support varies; 4 is the practical ask).
* *
* Firefox: BarcodeDetector not yet supported — shows an informative fallback. * Firefox: BarcodeDetector not yet supported — shows an informative fallback.
*/ */
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
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, ae_loc } from '$lib/stores/ae_stores'; import { ae_api, ae_loc } 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/ae_events_functions'; import { events_func } from '$lib/ae_events/ae_events_functions';
import { ae_util } from '$lib/ae_utils/ae_utils'; import { ae_util } from '$lib/ae_utils/ae_utils';
import type { ae_EventBadge } from '$lib/types/ae_types'; import type { ae_EventBadge } from '$lib/types/ae_types';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import { import {
CircleCheck, Eye, Layers, LoaderCircle, CircleCheck,
RefreshCw, RotateCcw, ScanLine, ShieldOff, UserPlus, X Eye,
} from '@lucide/svelte'; Layers,
LoaderCircle,
RefreshCw,
RotateCcw,
ScanLine,
ShieldOff,
UserPlus,
X
} from '@lucide/svelte';
interface Props { interface Props {
exhibit_id: string; exhibit_id: string;
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, on_lead_added }: Props = $props();
// BarcodeDetector is in Chrome/Edge/Safari 17+; not yet in Firefox. // BarcodeDetector is in Chrome/Edge/Safari 17+; not yet in Firefox.
// Check at runtime — TypeScript lib.dom.d.ts may not have it yet. // Check at runtime — TypeScript lib.dom.d.ts may not have it yet.
const is_supported = typeof window !== 'undefined' && 'BarcodeDetector' in window; const is_supported =
typeof window !== 'undefined' && 'BarcodeDetector' in window;
// --- Types --- // --- Types ---
type BatchStatus = 'loading' | 'ready' | 'blocked' | 'already_added' | 'reenable' | 'adding' | 'added' | 'error'; type BatchStatus =
| 'loading'
| 'ready'
| 'blocked'
| 'already_added'
| 'reenable'
| 'adding'
| 'added'
| 'error';
interface BatchItem { interface BatchItem {
id: string; // badge id_random from QR id: string; // badge id_random from QR
badge: ae_EventBadge | null; badge: ae_EventBadge | null;
status: BatchStatus; status: BatchStatus;
existing_tracking_id: string; // set when status === 'already_added' existing_tracking_id: string; // set when status === 'already_added'
dismissing: boolean; // true while CSS fade-out plays dismissing: boolean; // true while CSS fade-out plays
} }
// --- Existing leads (duplicate detection) --- // --- Existing leads (duplicate detection) ---
// Value includes tracking_id AND enabled status so we can offer re-activation for removed leads. // Value includes tracking_id AND enabled status so we can offer re-activation for removed leads.
let existing_leads_map = $derived( let existing_leads_map = $derived(
liveQuery(async () => { liveQuery(async () => {
const leads = await db_events.exhibit_tracking const leads = await db_events.exhibit_tracking
.where('event_exhibit_id').equals(exhibit_id).toArray(); .where('event_exhibit_id')
const map = new SvelteMap<string, { tracking_id: string; enabled: boolean }>(); .equals(exhibit_id)
leads.forEach(l => { .toArray();
const b_id = l.event_badge_id?.toString(); const map = new SvelteMap<
if (b_id) map.set(b_id, { string,
{ tracking_id: string; enabled: boolean }
>();
leads.forEach((l) => {
const b_id = l.event_badge_id?.toString();
if (b_id)
map.set(b_id, {
tracking_id: l.event_exhibit_tracking_id?.toString() || '', tracking_id: l.event_exhibit_tracking_id?.toString() || '',
enabled: !!l.enable enabled: !!l.enable
}); });
}); });
return map; return map;
}) })
); );
// --- Camera --- // --- Camera ---
let video_el = $state<HTMLVideoElement | undefined>(undefined); let video_el = $state<HTMLVideoElement | undefined>(undefined);
let stream: MediaStream | null = null; let stream: MediaStream | null = null;
let detector: any = null; let detector: any = null;
let camera_status = $state<'idle' | 'starting' | 'live' | 'capturing' | 'error'>('idle'); let camera_status = $state<
let camera_error = $state(''); 'idle' | 'starting' | 'live' | 'capturing' | 'error'
>('idle');
let camera_error = $state('');
// Start camera when the video element mounts // Start camera when the video element mounts
$effect(() => { $effect(() => {
if (!video_el || !is_supported) return; if (!video_el || !is_supported) return;
start_camera(); start_camera();
return () => stop_camera(); return () => stop_camera();
}); });
onDestroy(stop_camera); onDestroy(stop_camera);
async function start_camera() { async function start_camera() {
if (camera_status !== 'idle') return; if (camera_status !== 'idle') return;
camera_status = 'starting'; camera_status = 'starting';
try { try {
stream = await navigator.mediaDevices.getUserMedia({ stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment', width: { ideal: 1920 }, height: { ideal: 1080 } } video: {
}); facingMode: 'environment',
if (!video_el) { stop_camera(); return; } width: { ideal: 1920 },
video_el.srcObject = stream; height: { ideal: 1080 }
await video_el.play(); }
// BarcodeDetector API — not yet typed in lib.dom.d.ts for all targets });
detector = new (window as any).BarcodeDetector({ formats: ['qr_code'] }); if (!video_el) {
camera_status = 'live'; stop_camera();
} catch (e: any) { return;
camera_status = 'error'; }
camera_error = e?.name === 'NotAllowedError' video_el.srcObject = stream;
await video_el.play();
// BarcodeDetector API — not yet typed in lib.dom.d.ts for all targets
detector = new (window as any).BarcodeDetector({
formats: ['qr_code']
});
camera_status = 'live';
} catch (e: any) {
camera_status = 'error';
camera_error =
e?.name === 'NotAllowedError'
? 'Camera access denied. Allow camera access and try again.' ? 'Camera access denied. Allow camera access and try again.'
: 'Could not start camera. Please try again.'; : 'Could not start camera. Please try again.';
}
}
function stop_camera() {
stream?.getTracks().forEach((t) => t.stop());
stream = null;
detector = null;
if (camera_status !== 'error') camera_status = 'idle';
}
async function retry_camera() {
camera_status = 'idle';
await start_camera();
}
// --- Batch ---
let batch = $state<BatchItem[]>([]);
let ready_count = $derived(
batch.filter((i) => i.status === 'ready' && !i.dismissing).length
);
async function capture_batch() {
if (!detector || !video_el || camera_status !== 'live') return;
camera_status = 'capturing';
try {
const barcodes: Array<{ rawValue: string }> =
await detector.detect(video_el);
const existing_ids = new Set(batch.map((i) => i.id));
const new_objs = barcodes
.map((b) => ae_util.process_data_string(b.rawValue))
.filter(
(obj): obj is { type: string; id: string } =>
!!(obj && obj.type === 'event_badge' && obj.id)
)
.filter((obj) => !existing_ids.has(obj.id))
.slice(0, Math.max(0, 8 - batch.length)); // hard cap at 8 total
for (const obj of new_objs) {
const item: BatchItem = {
id: obj.id,
badge: null,
status: 'loading',
existing_tracking_id: '',
dismissing: false
};
batch.push(item);
load_badge(item); // fire-and-forget; updates item in place
} }
} catch (e) {
console.error('[Multi] BarcodeDetector.detect failed:', e);
} }
function stop_camera() { camera_status = 'live';
stream?.getTracks().forEach(t => t.stop()); }
stream = null;
detector = null;
if (camera_status !== 'error') camera_status = 'idle';
}
async function retry_camera() { async function load_badge(item: BatchItem) {
camera_status = 'idle'; try {
await start_camera(); const badge = await events_func.load_ae_obj_id__event_badge({
} api_cfg: $ae_api,
event_badge_id: item.id,
log_lvl: 1
});
item.badge = badge;
// --- Batch --- if ($existing_leads_map?.has(item.id)) {
let batch = $state<BatchItem[]>([]); const existing = $existing_leads_map.get(item.id)!;
let ready_count = $derived(batch.filter(i => i.status === 'ready' && !i.dismissing).length); item.existing_tracking_id = existing.tracking_id;
// Distinguish: active lead vs previously-removed lead
async function capture_batch() { item.status = existing.enabled ? 'already_added' : 'reenable';
if (!detector || !video_el || camera_status !== 'live') return; } else if (badge?.allow_tracking !== true) {
camera_status = 'capturing'; // Attendee has opted out — show card so staff can inform them
item.status = 'blocked';
try { } else {
const barcodes: Array<{ rawValue: string }> = await detector.detect(video_el); item.status = 'ready';
const existing_ids = new Set(batch.map(i => i.id));
const new_objs = barcodes
.map(b => ae_util.process_data_string(b.rawValue))
.filter((obj): obj is { type: string; id: string } =>
!!(obj && obj.type === 'event_badge' && obj.id))
.filter(obj => !existing_ids.has(obj.id))
.slice(0, Math.max(0, 8 - batch.length)); // hard cap at 8 total
for (const obj of new_objs) {
const item: BatchItem = {
id: obj.id,
badge: null,
status: 'loading',
existing_tracking_id: '',
dismissing: false
};
batch.push(item);
load_badge(item); // fire-and-forget; updates item in place
}
} catch (e) {
console.error('[Multi] BarcodeDetector.detect failed:', e);
} }
} catch {
camera_status = 'live'; item.status = 'error';
} }
}
async function load_badge(item: BatchItem) { async function add_lead(item: BatchItem) {
try { if (item.status !== 'ready' || !item.badge?.event_badge_id) return;
const badge = await events_func.load_ae_obj_id__event_badge({ item.status = 'adding';
api_cfg: $ae_api,
event_badge_id: item.id,
log_lvl: 1
});
item.badge = badge;
if ($existing_leads_map?.has(item.id)) { const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const existing = $existing_leads_map.get(item.id)!; const user_email =
item.existing_tracking_id = existing.tracking_id; kv?.type === 'licensed' && kv.key
// Distinguish: active lead vs previously-removed lead ? kv.key
item.status = existing.enabled ? 'already_added' : 'reenable'; : kv?.type === 'shared'
} else if (badge?.allow_tracking !== true) { ? 'shared_passcode'
// Attendee has opted out — show card so staff can inform them : $ae_loc.access_type || 'anonymous';
item.status = 'blocked'; try {
} else { const result = await events_func.create_ae_obj__exhibit_tracking({
item.status = 'ready'; api_cfg: $ae_api,
} exhibit_id,
} catch { event_badge_id: item.badge.event_badge_id,
external_person_id: user_email,
group: user_email
});
if (result) {
item.status = 'added';
if (on_lead_added) on_lead_added(item.badge);
// Brief success display, then fade out
setTimeout(() => dismiss_item(item), 1000);
} else {
// API returned false/null — network error, API down, or auth failure.
item.status = 'error'; item.status = 'error';
} }
} catch {
item.status = 'error';
} }
}
async function add_lead(item: BatchItem) { async function reenable_lead(item: BatchItem) {
if (item.status !== 'ready' || !item.badge?.event_badge_id) return; if (!item.existing_tracking_id) return;
item.status = 'adding'; item.status = 'adding';
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id]; try {
const user_email = kv?.type === 'licensed' && kv.key ? kv.key const result = await events_func.update_ae_obj__exhibit_tracking({
: kv?.type === 'shared' ? 'shared_passcode' api_cfg: $ae_api,
: $ae_loc.access_type || 'anonymous'; exhibit_id,
try { exhibit_tracking_id: item.existing_tracking_id,
const result = await events_func.create_ae_obj__exhibit_tracking({ data: { enable: true }
api_cfg: $ae_api, });
exhibit_id, if (result) {
event_badge_id: item.badge.event_badge_id, item.status = 'added';
external_person_id: user_email, if (on_lead_added && item.badge) on_lead_added(item.badge);
group: user_email setTimeout(() => dismiss_item(item), 1000);
}); } else {
if (result) {
item.status = 'added';
if (on_lead_added) on_lead_added(item.badge);
// Brief success display, then fade out
setTimeout(() => dismiss_item(item), 1000);
} else {
// API returned false/null — network error, API down, or auth failure.
item.status = 'error';
}
} catch {
item.status = 'error'; item.status = 'error';
} }
} catch {
item.status = 'error';
} }
}
async function reenable_lead(item: BatchItem) { async function add_all() {
if (!item.existing_tracking_id) return; const to_add = batch.filter((i) => i.status === 'ready' && !i.dismissing);
item.status = 'adding'; await Promise.all(to_add.map(add_lead));
}
try { function dismiss_item(item: BatchItem) {
const result = await events_func.update_ae_obj__exhibit_tracking({ item.dismissing = true;
api_cfg: $ae_api, // Remove from array after CSS transition completes (300ms)
exhibit_id, setTimeout(() => {
exhibit_tracking_id: item.existing_tracking_id, const idx = batch.findIndex((i) => i.id === item.id);
data: { enable: true } if (idx >= 0) batch.splice(idx, 1);
}); }, 350);
if (result) { }
item.status = 'added';
if (on_lead_added && item.badge) on_lead_added(item.badge);
setTimeout(() => dismiss_item(item), 1000);
} else {
item.status = 'error';
}
} catch {
item.status = 'error';
}
}
async function add_all() {
const to_add = batch.filter(i => i.status === 'ready' && !i.dismissing);
await Promise.all(to_add.map(add_lead));
}
function dismiss_item(item: BatchItem) {
item.dismissing = true;
// Remove from array after CSS transition completes (300ms)
setTimeout(() => {
const idx = batch.findIndex(i => i.id === item.id);
if (idx >= 0) batch.splice(idx, 1);
}, 350);
}
</script> </script>
<div class="multi-scanner flex flex-col items-center gap-4 w-full"> <div class="multi-scanner flex w-full flex-col items-center gap-4">
{#if !is_supported} {#if !is_supported}
<!-- Firefox / older browser fallback --> <!-- Firefox / older browser fallback -->
<div class="card p-6 w-full max-w-md space-y-3 preset-tonal-warning text-center border-2 border-warning-500"> <div
<Layers size="2.5em" class="mx-auto text-warning-500" /> class="card preset-tonal-warning border-warning-500 w-full max-w-md space-y-3 border-2 p-6 text-center">
<Layers size="2.5em" class="text-warning-500 mx-auto" />
<h3 class="h4 font-bold">Multi-Scan Not Available</h3> <h3 class="h4 font-bold">Multi-Scan Not Available</h3>
<p class="text-sm opacity-70"> <p class="text-sm opacity-70">
Multi-scan uses the browser's BarcodeDetector API, which is supported in Multi-scan uses the browser's BarcodeDetector API, which is
Chrome, Edge, and Safari 17+. Firefox support is coming soon. supported in Chrome, Edge, and Safari 17+. Firefox support is
coming soon.
</p>
<p class="text-sm opacity-70">
Use <strong>Rapid</strong> or <strong>Auto</strong> mode in the meantime.
</p> </p>
<p class="text-sm opacity-70">Use <strong>Rapid</strong> or <strong>Auto</strong> mode in the meantime.</p>
</div> </div>
{:else} {:else}
<!-- Camera viewfinder — landscape 16:9 gives more horizontal coverage for multiple badges --> <!-- Camera viewfinder — landscape 16:9 gives more horizontal coverage for multiple badges -->
<div class="w-full max-w-md relative rounded-xl overflow-hidden border-4 border-surface-500/20 shadow-xl bg-black aspect-video"> <div
class="border-surface-500/20 relative aspect-video w-full max-w-md overflow-hidden rounded-xl border-4 bg-black shadow-xl">
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
<video <video
bind:this={video_el} bind:this={video_el}
class="w-full h-full object-cover" class="h-full w-full object-cover"
playsinline playsinline
muted muted></video>
></video>
<!-- Hint overlay: shown while camera is live, styled like a check-deposit scanner guide --> <!-- Hint overlay: shown while camera is live, styled like a check-deposit scanner guide -->
{#if camera_status === 'live'} {#if camera_status === 'live'}
<div class="absolute inset-0 pointer-events-none flex flex-col justify-end items-center pb-3 px-4"> <div
<span class="bg-black/50 text-white text-xs font-semibold px-3 py-1.5 rounded-full tracking-wide text-center"> class="pointer-events-none absolute inset-0 flex flex-col items-center justify-end px-4 pb-3">
<span
class="rounded-full bg-black/50 px-3 py-1.5 text-center text-xs font-semibold tracking-wide text-white">
Align up to 4 badges flat in frame Align up to 4 badges flat in frame
</span> </span>
</div> </div>
<!-- Corner guides — visual aid for badge alignment --> <!-- Corner guides — visual aid for badge alignment -->
<div class="absolute inset-4 pointer-events-none"> <div class="pointer-events-none absolute inset-4">
<div class="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-primary-400/70 rounded-tl"></div> <div
<div class="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-primary-400/70 rounded-tr"></div> class="border-primary-400/70 absolute top-0 left-0 h-6 w-6 rounded-tl border-t-2 border-l-2">
<div class="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-primary-400/70 rounded-bl"></div> </div>
<div class="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-primary-400/70 rounded-br"></div> <div
class="border-primary-400/70 absolute top-0 right-0 h-6 w-6 rounded-tr border-t-2 border-r-2">
</div>
<div
class="border-primary-400/70 absolute bottom-0 left-0 h-6 w-6 rounded-bl border-b-2 border-l-2">
</div>
<div
class="border-primary-400/70 absolute right-0 bottom-0 h-6 w-6 rounded-br border-r-2 border-b-2">
</div>
</div> </div>
{/if} {/if}
<!-- Starting overlay --> <!-- Starting overlay -->
{#if camera_status === 'starting'} {#if camera_status === 'starting'}
<div class="absolute inset-0 flex items-center justify-center bg-black/40"> <div
<span class="bg-black/60 text-white text-sm font-semibold px-4 py-2 rounded-full animate-pulse shadow-lg"> class="absolute inset-0 flex items-center justify-center bg-black/40">
<span
class="animate-pulse rounded-full bg-black/60 px-4 py-2 text-sm font-semibold text-white shadow-lg">
Starting camera... Starting camera...
</span> </span>
</div> </div>
@@ -300,13 +355,16 @@
<!-- Error overlay --> <!-- Error overlay -->
{#if camera_status === 'error'} {#if camera_status === 'error'}
<div class="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-black/80 rounded-xl p-6 text-center"> <div
<p class="text-white text-sm font-semibold leading-snug drop-shadow">{camera_error}</p> class="absolute inset-0 flex flex-col items-center justify-center gap-4 rounded-xl bg-black/80 p-6 text-center">
<p
class="text-sm leading-snug font-semibold text-white drop-shadow">
{camera_error}
</p>
<button <button
type="button" type="button"
class="bg-white text-surface-950 hover:bg-surface-100 font-bold text-base px-8 py-3 rounded-xl shadow-lg transition-colors cursor-pointer flex items-center gap-2" class="text-surface-950 hover:bg-surface-100 flex cursor-pointer items-center gap-2 rounded-xl bg-white px-8 py-3 text-base font-bold shadow-lg transition-colors"
onclick={retry_camera} onclick={retry_camera}>
>
<RefreshCw size="1.2em" /> <RefreshCw size="1.2em" />
Try Again Try Again
</button> </button>
@@ -318,10 +376,9 @@
{#if camera_status === 'live' || camera_status === 'capturing'} {#if camera_status === 'live' || camera_status === 'capturing'}
<button <button
type="button" type="button"
class="w-full max-w-md rounded-xl py-4 font-bold text-base flex items-center justify-center gap-2 bg-primary-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer disabled:opacity-50" class="bg-primary-500 flex w-full max-w-md cursor-pointer items-center justify-center gap-2 rounded-xl py-4 text-base font-bold text-white shadow-md transition-all hover:brightness-110 active:brightness-90 disabled:opacity-50"
disabled={camera_status === 'capturing'} disabled={camera_status === 'capturing'}
onclick={capture_batch} onclick={capture_batch}>
>
{#if camera_status === 'capturing'} {#if camera_status === 'capturing'}
<LoaderCircle class="animate-spin" size="1.3em" /> <LoaderCircle class="animate-spin" size="1.3em" />
Scanning... Scanning...
@@ -334,143 +391,172 @@
<!-- Badge grid --> <!-- Badge grid -->
{#if batch.length > 0} {#if batch.length > 0}
<div class="w-full grid grid-cols-1 sm:grid-cols-2 gap-3"> <div class="grid w-full grid-cols-1 gap-3 sm:grid-cols-2">
{#each batch as item (item.id)} {#each batch as item (item.id)}
<div <div
class="batch-card card p-4 space-y-3 bg-surface-50-900 border border-surface-500/20 shadow min-h-28" class="batch-card card bg-surface-50-900 border-surface-500/20 min-h-28 space-y-3 border p-4 shadow"
class:dismissing={item.dismissing} class:dismissing={item.dismissing}>
>
{#if item.status === 'loading'} {#if item.status === 'loading'}
<!-- Skeleton — fixed height prevents layout bounce as badges load --> <!-- Skeleton — fixed height prevents layout bounce as badges load -->
<div class="space-y-2"> <div class="space-y-2">
<div class="h-5 w-3/4 bg-surface-200-800 animate-pulse rounded"></div> <div
<div class="h-4 w-1/2 bg-surface-200-800 animate-pulse rounded"></div> class="bg-surface-200-800 h-5 w-3/4 animate-pulse rounded">
</div>
<div
class="bg-surface-200-800 h-4 w-1/2 animate-pulse rounded">
</div>
</div>
<div
class="bg-surface-200-800 h-9 animate-pulse rounded-lg">
</div> </div>
<div class="h-9 bg-surface-200-800 animate-pulse rounded-lg"></div>
{:else if item.status === 'blocked'} {:else if item.status === 'blocked'}
<!-- Tracking opt-out — show card so staff can inform the attendee --> <!-- Tracking opt-out — show card so staff can inform the attendee -->
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<ShieldOff size="1.2em" class="text-warning-500 shrink-0 mt-0.5" /> <ShieldOff
size="1.2em"
class="text-warning-500 mt-0.5 shrink-0" />
<div> <div>
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || 'Attendee'}</p> <p class="text-sm leading-tight font-bold">
<p class="text-xs opacity-60 mt-0.5">Opted out of lead scanning</p> {item.badge?.full_name || 'Attendee'}
</p>
<p class="mt-0.5 text-xs opacity-60">
Opted out of lead scanning
</p>
</div> </div>
</div> </div>
<button <button
type="button" type="button"
class="w-full rounded-lg py-2 text-sm font-bold flex items-center justify-center gap-1.5 border border-warning-500/40 text-warning-600 dark:text-warning-400 hover:bg-warning-500/10 transition-colors cursor-pointer" class="border-warning-500/40 text-warning-600 dark:text-warning-400 hover:bg-warning-500/10 flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border py-2 text-sm font-bold transition-colors"
onclick={() => dismiss_item(item)} onclick={() => dismiss_item(item)}>
>
<X size="1em" /> <X size="1em" />
OK, Dismiss OK, Dismiss
</button> </button>
{:else if item.status === 'already_added'} {:else if item.status === 'already_added'}
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<CircleCheck size="1.2em" class="text-secondary-500 shrink-0 mt-0.5" /> <CircleCheck
size="1.2em"
class="text-secondary-500 mt-0.5 shrink-0" />
<div> <div>
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || 'Attendee'}</p> <p class="text-sm leading-tight font-bold">
<p class="text-xs opacity-60 mt-0.5">Already captured</p> {item.badge?.full_name || 'Attendee'}
</p>
<p class="mt-0.5 text-xs opacity-60">
Already captured
</p>
</div> </div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<a <a
href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${item.existing_tracking_id}`} href={`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${item.existing_tracking_id}`}
class="flex-1 rounded-lg py-2 text-xs font-bold flex items-center justify-center gap-1 border border-surface-500/40 hover:bg-surface-200-800 transition-colors" class="border-surface-500/40 hover:bg-surface-200-800 flex flex-1 items-center justify-center gap-1 rounded-lg border py-2 text-xs font-bold transition-colors">
>
<Eye size="0.9em" /> <Eye size="0.9em" />
View View
</a> </a>
<button <button
type="button" type="button"
class="flex-1 rounded-lg py-2 text-xs font-bold flex items-center justify-center gap-1 border border-surface-500/40 hover:bg-surface-200-800 transition-colors cursor-pointer" class="border-surface-500/40 hover:bg-surface-200-800 flex flex-1 cursor-pointer items-center justify-center gap-1 rounded-lg border py-2 text-xs font-bold transition-colors"
onclick={() => dismiss_item(item)} onclick={() => dismiss_item(item)}>
>
<X size="0.9em" /> <X size="0.9em" />
OK OK
</button> </button>
</div> </div>
{:else if item.status === 'reenable'} {:else if item.status === 'reenable'}
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<RotateCcw size="1.2em" class="text-warning-500 shrink-0 mt-0.5" /> <RotateCcw
size="1.2em"
class="text-warning-500 mt-0.5 shrink-0" />
<div> <div>
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || 'Attendee'}</p> <p class="text-sm leading-tight font-bold">
<p class="text-xs opacity-60 mt-0.5">Previously removed</p> {item.badge?.full_name || 'Attendee'}
</p>
<p class="mt-0.5 text-xs opacity-60">
Previously removed
</p>
</div> </div>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
type="button" type="button"
class="flex-1 rounded-lg py-2 text-xs font-bold flex items-center justify-center gap-1.5 bg-warning-500 text-white hover:brightness-110 transition-all cursor-pointer" class="bg-warning-500 flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg py-2 text-xs font-bold text-white transition-all hover:brightness-110"
onclick={() => reenable_lead(item)} onclick={() => reenable_lead(item)}>
>
<RotateCcw size="0.9em" /> <RotateCcw size="0.9em" />
Re-activate Re-activate
</button> </button>
<button <button
type="button" type="button"
class="flex-none rounded-lg px-3 py-2 flex items-center justify-center border border-surface-500/40 hover:bg-surface-200-800 transition-colors cursor-pointer opacity-60" class="border-surface-500/40 hover:bg-surface-200-800 flex flex-none cursor-pointer items-center justify-center rounded-lg border px-3 py-2 opacity-60 transition-colors"
title="Skip" title="Skip"
onclick={() => dismiss_item(item)} onclick={() => dismiss_item(item)}>
>
<X size="1em" /> <X size="1em" />
</button> </button>
</div> </div>
{:else if item.status === 'ready'} {:else if item.status === 'ready'}
<div> <div>
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || 'Badge Found'}</p> <p class="text-sm leading-tight font-bold">
<p class="text-xs opacity-60">{item.badge?.affiliations || ''}</p> {item.badge?.full_name || 'Badge Found'}
</p>
<p class="text-xs opacity-60">
{item.badge?.affiliations || ''}
</p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button
type="button" type="button"
class="flex-1 rounded-lg py-2.5 text-sm font-bold flex items-center justify-center gap-1.5 bg-primary-500 text-white hover:brightness-110 transition-all cursor-pointer" class="bg-primary-500 flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-lg py-2.5 text-sm font-bold text-white transition-all hover:brightness-110"
onclick={() => add_lead(item)} onclick={() => add_lead(item)}>
>
<UserPlus size="1em" /> <UserPlus size="1em" />
Add Add
</button> </button>
<button <button
type="button" type="button"
class="flex-none rounded-lg px-3 py-2.5 flex items-center justify-center border border-surface-500/40 hover:bg-surface-200-800 transition-colors cursor-pointer opacity-60" class="border-surface-500/40 hover:bg-surface-200-800 flex flex-none cursor-pointer items-center justify-center rounded-lg border px-3 py-2.5 opacity-60 transition-colors"
title="Skip this badge" title="Skip this badge"
onclick={() => dismiss_item(item)} onclick={() => dismiss_item(item)}>
>
<X size="1em" /> <X size="1em" />
</button> </button>
</div> </div>
{:else if item.status === 'adding'} {:else if item.status === 'adding'}
<div class="flex items-center gap-2 py-1 opacity-70"> <div
<LoaderCircle size="1.2em" class="animate-spin text-primary-500 shrink-0" /> class="flex items-center gap-2 py-1 opacity-70">
<LoaderCircle
size="1.2em"
class="text-primary-500 shrink-0 animate-spin" />
<div> <div>
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || '...'}</p> <p class="text-sm leading-tight font-bold">
{item.badge?.full_name || '...'}
</p>
<p class="text-xs opacity-60">Adding...</p> <p class="text-xs opacity-60">Adding...</p>
</div> </div>
</div> </div>
{:else if item.status === 'added'} {:else if item.status === 'added'}
<div class="flex items-center gap-2 py-1"> <div class="flex items-center gap-2 py-1">
<CircleCheck size="1.2em" class="text-success-500 shrink-0" /> <CircleCheck
size="1.2em"
class="text-success-500 shrink-0" />
<div> <div>
<p class="font-bold text-sm leading-tight text-success-600 dark:text-success-400">{item.badge?.full_name || 'Lead'}</p> <p
<p class="text-xs opacity-60">Lead added!</p> class="text-success-600 dark:text-success-400 text-sm leading-tight font-bold">
{item.badge?.full_name || 'Lead'}
</p>
<p class="text-xs opacity-60">
Lead added!
</p>
</div> </div>
</div> </div>
{:else if item.status === 'error'} {:else if item.status === 'error'}
<div> <div>
<p class="text-sm font-bold text-error-600 dark:text-error-400">Failed to add</p> <p
<p class="text-xs opacity-60">{item.badge?.full_name || 'Unknown'}</p> class="text-error-600 dark:text-error-400 text-sm font-bold">
Failed to add
</p>
<p class="text-xs opacity-60">
{item.badge?.full_name || 'Unknown'}
</p>
</div> </div>
<button <button
type="button" type="button"
class="w-full rounded-lg py-2 text-xs font-bold flex items-center justify-center gap-1.5 border border-error-500/40 text-error-600 dark:text-error-400 hover:bg-error-500/10 transition-colors cursor-pointer" class="border-error-500/40 text-error-600 dark:text-error-400 hover:bg-error-500/10 flex w-full cursor-pointer items-center justify-center gap-1.5 rounded-lg border py-2 text-xs font-bold transition-colors"
onclick={() => dismiss_item(item)} onclick={() => dismiss_item(item)}>
>
<X size="1em" /> <X size="1em" />
Dismiss Dismiss
</button> </button>
@@ -483,27 +569,27 @@
{#if ready_count > 0} {#if ready_count > 0}
<button <button
type="button" type="button"
class="w-full rounded-xl py-4 font-bold text-base flex items-center justify-center gap-2 bg-primary-500 text-white shadow-md hover:brightness-110 active:brightness-90 transition-all cursor-pointer" class="bg-primary-500 flex w-full cursor-pointer items-center justify-center gap-2 rounded-xl py-4 text-base font-bold text-white shadow-md transition-all hover:brightness-110 active:brightness-90"
onclick={add_all} onclick={add_all}>
>
<UserPlus size="1.3em" /> <UserPlus size="1.3em" />
Add All ({ready_count}) Add All ({ready_count})
</button> </button>
{/if} {/if}
{/if} {/if}
{/if} {/if}
</div> </div>
<style> <style>
/* Smooth fade-and-shrink when a card is dismissed (Add / Skip / OK). /* Smooth fade-and-shrink when a card is dismissed (Add / Skip / OK).
Duration matches the 350ms setTimeout in dismiss_item(). */ Duration matches the 350ms setTimeout in dismiss_item(). */
.batch-card { .batch-card {
transition: opacity 0.3s ease, transform 0.3s ease; transition:
} opacity 0.3s ease,
.batch-card.dismissing { transform 0.3s ease;
opacity: 0; }
transform: scale(0.9); .batch-card.dismissing {
pointer-events: none; opacity: 0;
} transform: scale(0.9);
pointer-events: none;
}
</style> </style>

View File

@@ -1,81 +1,97 @@
<script lang="ts"> <script lang="ts">
/** /**
* 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: * Two orthogonal toggles:
* - mode: 'qr' | 'search' — how to find the attendee * - mode: 'qr' | 'search' — how to find the attendee
* - scan_qualify: 'rapid' | 'qualify' | 'auto' | 'multi' — what to do after finding (QR mode only) * - scan_qualify: 'rapid' | 'qualify' | 'auto' | 'multi' — what to do after finding (QR mode only)
* rapid: confirm tap → auto-reset → scan next person immediately * rapid: confirm tap → auto-reset → scan next person immediately
* qualify: confirm tap → navigate to lead detail → fill qualifiers/notes * qualify: confirm tap → navigate to lead detail → fill qualifiers/notes
* auto: no confirm — badge is added immediately on scan → auto-reset * auto: no confirm — badge is added immediately on scan → auto-reset
* multi: BarcodeDetector batch scan → grid of confirm cards * multi: BarcodeDetector batch scan → grid of confirm cards
*/ */
import { Bot, ChevronDown, Layers, QrCode, Search, Zap } from '@lucide/svelte'; import { Bot, ChevronDown, Layers, QrCode, Search, Zap } 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_qr_scanner_multi from './ae_comp__lead_qr_scanner_multi.svelte'; import Comp_lead_qr_scanner_multi from './ae_comp__lead_qr_scanner_multi.svelte';
import Comp_lead_manual_search from './ae_comp__lead_manual_search.svelte'; import 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';
interface Props { interface Props {
exhibit_id: string; exhibit_id: string;
} }
let { exhibit_id }: Props = $props(); let { exhibit_id }: Props = $props();
// QR vs Manual Search (persisted per exhibit) // 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) {
if (!$events_loc.leads.tab_add_mode) $events_loc.leads.tab_add_mode = {}; if (!$events_loc.leads.tab_add_mode) $events_loc.leads.tab_add_mode = {};
$events_loc.leads.tab_add_mode[exhibit_id] = new_mode; $events_loc.leads.tab_add_mode[exhibit_id] = new_mode;
} }
// Scan qualify mode (persisted per exhibit) // Scan qualify mode (persisted per exhibit)
// 'qualify' was merged into 'rapid' — normalize stale localStorage values
type ScanQualifyMode = 'rapid' | 'auto' | 'multi';
let scan_qualify = $derived.by(() => {
const raw = $events_loc.leads.tab_scan_qualify?.[exhibit_id] ?? 'rapid';
// 'qualify' was merged into 'rapid' — normalize stale localStorage values // 'qualify' was merged into 'rapid' — normalize stale localStorage values
type ScanQualifyMode = 'rapid' | 'auto' | 'multi'; return (raw === 'qualify' ? 'rapid' : raw) as ScanQualifyMode;
let scan_qualify = $derived.by(() => { });
const raw = $events_loc.leads.tab_scan_qualify?.[exhibit_id] ?? 'rapid';
// 'qualify' was merged into 'rapid' — normalize stale localStorage values
return (raw === 'qualify' ? 'rapid' : raw) as ScanQualifyMode;
});
function set_scan_qualify(new_mode: ScanQualifyMode) { function set_scan_qualify(new_mode: ScanQualifyMode) {
if (!$events_loc.leads.tab_scan_qualify) $events_loc.leads.tab_scan_qualify = {}; if (!$events_loc.leads.tab_scan_qualify)
$events_loc.leads.tab_scan_qualify[exhibit_id] = new_mode; $events_loc.leads.tab_scan_qualify = {};
show_mode_opts = false; $events_loc.leads.tab_scan_qualify[exhibit_id] = new_mode;
show_mode_opts = false;
}
function handle_lead_added(badge: any) {
$events_loc.leads.tracking__search_version++;
}
// Mode selector expand/collapse
let show_mode_opts = $state(false);
// Mode config — drives both the trigger display and the options grid
const qr_modes: Array<{
value: ScanQualifyMode;
label: string;
desc: string;
icon: any;
}> = [
{
value: 'rapid',
label: 'Confirm',
desc: 'Tap Add & Scan or Add & View',
icon: Zap
},
{
value: 'auto',
label: 'Auto',
desc: 'Auto-add · no tap needed',
icon: Bot
},
{
value: 'multi',
label: 'Multi',
desc: 'Batch scan up to 4 badges',
icon: Layers
} }
];
function handle_lead_added(badge: any) { let active_mode = $derived(
$events_loc.leads.tracking__search_version++; qr_modes.find((m) => m.value === scan_qualify) ?? qr_modes[0]
} );
// Mode selector expand/collapse
let show_mode_opts = $state(false);
// Mode config — drives both the trigger display and the options grid
const qr_modes: Array<{
value: ScanQualifyMode;
label: string;
desc: string;
icon: any;
}> = [
{ value: 'rapid', label: 'Confirm', desc: 'Tap Add & Scan or Add & View', icon: Zap },
{ value: 'auto', label: 'Auto', desc: 'Auto-add · no tap needed', icon: Bot },
{ value: 'multi', label: 'Multi', desc: 'Batch scan up to 4 badges', icon: Layers },
];
let active_mode = $derived(qr_modes.find(m => m.value === scan_qualify) ?? qr_modes[0]);
</script> </script>
<div class="ae-tab-add flex flex-col items-center gap-3 w-full mx-auto"> <div class="ae-tab-add mx-auto flex w-full flex-col items-center gap-3">
<!-- QR / Search toggle --> <!-- QR / Search toggle -->
<button <button
type="button" type="button"
class="btn btn-sm preset-filled-secondary font-bold shadow-sm px-4 py-2.5 flex items-center gap-2 w-full transition-all" class="btn btn-sm preset-filled-secondary flex w-full items-center gap-2 px-4 py-2.5 font-bold shadow-sm 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.1em" /> <Search size="1.1em" />
<span>Switch to Manual Search</span> <span>Switch to Manual Search</span>
@@ -88,60 +104,61 @@
<!-- Scan mode selector (QR mode only) --> <!-- Scan mode selector (QR mode only) -->
{#if mode === 'qr'} {#if mode === 'qr'}
<div class="w-full"> <div class="w-full">
<!-- Trigger: shows active mode, tapping expands options --> <!-- Trigger: shows active mode, tapping expands options -->
<button <button
type="button" type="button"
class="w-full flex items-center gap-3 px-4 py-3 rounded-xl bg-surface-100-900 border border-surface-500/20 shadow-sm transition-colors cursor-pointer" class="bg-surface-100-900 border-surface-500/20 flex w-full cursor-pointer items-center gap-3 rounded-xl border px-4 py-3 shadow-sm transition-colors"
onclick={() => show_mode_opts = !show_mode_opts} onclick={() => (show_mode_opts = !show_mode_opts)}
title="Change scan mode" title="Change scan mode">
>
<!-- Colored icon pill --> <!-- Colored icon pill -->
<span class="p-1.5 rounded-lg shrink-0 text-white" <span
class="shrink-0 rounded-lg p-1.5 text-white"
class:bg-primary-500={scan_qualify === 'rapid'} class:bg-primary-500={scan_qualify === 'rapid'}
class:bg-tertiary-500={scan_qualify === 'auto'} class:bg-tertiary-500={scan_qualify === 'auto'}
class:bg-warning-500={scan_qualify === 'multi'} class:bg-warning-500={scan_qualify === 'multi'}>
>
<active_mode.icon size="1em" /> <active_mode.icon size="1em" />
</span> </span>
<!-- Mode name + description --> <!-- Mode name + description -->
<div class="flex-1 text-left"> <div class="flex-1 text-left">
<span class="font-bold text-sm">{active_mode.label}</span> <span class="text-sm font-bold">{active_mode.label}</span>
<span class="text-xs opacity-50 ml-2">{active_mode.desc}</span> <span class="ml-2 text-xs opacity-50"
>{active_mode.desc}</span>
</div> </div>
<!-- Chevron --> <!-- Chevron -->
<ChevronDown <ChevronDown
size="1.1em" size="1.1em"
class="opacity-40 transition-transform duration-200 {show_mode_opts ? 'rotate-180' : ''}" class="opacity-40 transition-transform duration-200 {show_mode_opts
/> ? 'rotate-180'
: ''}" />
</button> </button>
<!-- Options grid (2×2) — shown when trigger is tapped --> <!-- Options grid (2×2) — shown when trigger is tapped -->
{#if show_mode_opts} {#if show_mode_opts}
<div class="mt-1.5 grid grid-cols-3 gap-2 p-2 bg-surface-50-900 rounded-xl border border-surface-500/20 shadow-lg"> <div
class="bg-surface-50-900 border-surface-500/20 mt-1.5 grid grid-cols-3 gap-2 rounded-xl border p-2 shadow-lg">
{#each qr_modes as m} {#each qr_modes as m}
<button <button
type="button" type="button"
class="flex flex-col items-center gap-1.5 p-3 rounded-lg text-center transition-all cursor-pointer" class="flex cursor-pointer flex-col items-center gap-1.5 rounded-lg p-3 text-center transition-all"
class:bg-surface-100-900={scan_qualify !== m.value} class:bg-surface-100-900={scan_qualify !== m.value}
class:opacity-50={scan_qualify !== m.value} class:opacity-50={scan_qualify !== m.value}
class:bg-surface-50-900={scan_qualify === m.value} class:bg-surface-50-900={scan_qualify === m.value}
class:shadow={scan_qualify === m.value} class:shadow={scan_qualify === m.value}
class:ring-1={scan_qualify === m.value} class:ring-1={scan_qualify === m.value}
class:ring-surface-500={scan_qualify === m.value} class:ring-surface-500={scan_qualify === m.value}
onclick={() => set_scan_qualify(m.value)} onclick={() => set_scan_qualify(m.value)}>
> <span
<span class="p-1.5 rounded-lg text-white" class="rounded-lg p-1.5 text-white"
class:bg-primary-500={m.value === 'rapid'} class:bg-primary-500={m.value === 'rapid'}
class:bg-tertiary-500={m.value === 'auto'} class:bg-tertiary-500={m.value === 'auto'}
class:bg-warning-500={m.value === 'multi'} class:bg-warning-500={m.value === 'multi'}>
>
<m.icon size="1.1em" /> <m.icon size="1.1em" />
</span> </span>
<span class="font-bold text-sm">{m.label}</span> <span class="text-sm font-bold">{m.label}</span>
<span class="text-[10px] opacity-60 leading-tight">{m.desc}</span> <span class="text-[10px] leading-tight opacity-60"
>{m.desc}</span>
</button> </button>
{/each} {/each}
</div> </div>
@@ -150,15 +167,22 @@
{/if} {/if}
<!-- Content Area --> <!-- Content Area -->
<div class="w-full flex flex-col items-center min-h-100"> <div class="flex min-h-100 w-full flex-col items-center">
{#if mode === 'qr'} {#if mode === 'qr'}
{#if scan_qualify === 'multi'} {#if scan_qualify === 'multi'}
<Comp_lead_qr_scanner_multi {exhibit_id} on_lead_added={handle_lead_added} /> <Comp_lead_qr_scanner_multi
{exhibit_id}
on_lead_added={handle_lead_added} />
{:else} {:else}
<Comp_lead_qr_scanner {exhibit_id} {scan_qualify} on_lead_added={handle_lead_added} /> <Comp_lead_qr_scanner
{exhibit_id}
{scan_qualify}
on_lead_added={handle_lead_added} />
{/if} {/if}
{: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}
</div> </div>
</div> </div>

View File

@@ -1,17 +1,17 @@
<script lang="ts"> <script lang="ts">
/** /**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__list.svelte * src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__list.svelte
* Tab 3: Leads List Stub. * Tab 3: Leads List Stub.
*/ */
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';
interface Props { interface Props {
lq__event_exhibit_tracking_obj_li: any; lq__event_exhibit_tracking_obj_li: any;
} }
let { lq__event_exhibit_tracking_obj_li }: Props = $props(); let { lq__event_exhibit_tracking_obj_li }: Props = $props();
</script> </script>
<div class="ae-tab-list p-4 space-y-4"> <div class="ae-tab-list space-y-4 p-4">
<h3 class="h3">Captured Leads</h3> <h3 class="h3">Captured Leads</h3>
<Comp_exhibit_tracking_obj_li {lq__event_exhibit_tracking_obj_li} /> <Comp_exhibit_tracking_obj_li {lq__event_exhibit_tracking_obj_li} />
</div> </div>

View File

@@ -1,74 +1,106 @@
<script lang="ts"> <script lang="ts">
/** /**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__manage.svelte * src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__manage.svelte
* Tab 4: Manage/Config - Exhibitor Settings and Profile. * Tab 4: Manage/Config - Exhibitor Settings and Profile.
*/ */
import { page } from '$app/state'; import { page } from '$app/state';
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, ae_loc } from '$lib/stores/ae_stores'; import { ae_api, ae_loc } 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 { events_func } from '$lib/ae_events/ae_events_functions'; import { events_func } from '$lib/ae_events/ae_events_functions';
import Element_ae_obj_field_editor from '$lib/elements/element_ae_obj_field_editor.svelte'; import Element_ae_obj_field_editor from '$lib/elements/element_ae_obj_field_editor.svelte';
import Comp_exhibit_license_list from './ae_comp__exhibit_license_list.svelte'; 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_custom_questions from './ae_comp__exhibit_custom_questions.svelte';
import Comp_exhibit_payment from './ae_comp__exhibit_payment.svelte'; import Comp_exhibit_payment from './ae_comp__exhibit_payment.svelte';
import { ChevronDown, ChevronRight, Clock, CreditCard, Database, Info, Key, Lock, LogOut, MessageSquare, RefreshCw, Settings, Store, UserX, Users } from '@lucide/svelte'; import {
const exhibit_id = $derived(page.params.exhibit_id ?? ''); ChevronDown,
ChevronRight,
Clock,
CreditCard,
Database,
Info,
Key,
Lock,
LogOut,
MessageSquare,
RefreshCw,
Settings,
Store,
UserX,
Users
} from '@lucide/svelte';
const exhibit_id = $derived(page.params.exhibit_id ?? '');
let lq__exhibit_obj = $derived( let lq__exhibit_obj = $derived(
liveQuery(async () => { liveQuery(async () => {
if (!exhibit_id) return null; if (!exhibit_id) return null;
return await db_events.exhibit.get(exhibit_id); return await db_events.exhibit.get(exhibit_id);
}) })
); );
// Track local status for specific actions // Track local status for specific actions
let updating = $state(false); let updating = $state(false);
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() { function handle_signout() {
if (confirm('Sign out from this booth?')) { if (confirm('Sign out from this booth?')) {
delete $events_loc.leads.auth_exhibit_kv[exhibit_id]; delete $events_loc.leads.auth_exhibit_kv[exhibit_id];
$events_sess.leads.entered_passcode = null; $events_sess.leads.entered_passcode = null;
// Navigate to start tab // Navigate to start tab
if (!$events_loc.leads.tab) $events_loc.leads.tab = {}; if (!$events_loc.leads.tab) $events_loc.leads.tab = {};
$events_loc.leads.tab[exhibit_id] = 'start'; $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 animate-in fade-in slide-in-from-bottom-2 w-full space-y-8 pb-20 duration-300">
<!-- Section: Admin Tools (Manager Access Only) --> <!-- Section: Admin Tools (Manager Access Only) -->
{#if $ae_loc.manager_access} {#if $ae_loc.manager_access}
<section class="space-y-4 p-4 border-2 border-primary-500/20 rounded-xl bg-primary-500/5"> <section
<div class="flex items-center gap-2 border-b border-primary-500/10 pb-2"> class="border-primary-500/20 bg-primary-500/5 space-y-4 rounded-xl border-2 p-4">
<div
class="border-primary-500/10 flex items-center gap-2 border-b pb-2">
<Settings size="1.2em" class="text-primary-500" /> <Settings size="1.2em" class="text-primary-500" />
<h3 class="text-lg font-bold uppercase tracking-wider text-primary-500">Admin Tools</h3> <h3
class="text-primary-500 text-lg font-bold tracking-wider uppercase">
Admin Tools
</h3>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Priority / Payment Toggle --> <!-- Priority / Payment Toggle -->
<div class="card p-3 preset-tonal-surface flex items-center justify-between"> <div
<div class="text-[10px] uppercase font-black opacity-40">Payment Status</div> class="card preset-tonal-surface flex items-center justify-between p-3">
<div class="text-[10px] font-black uppercase opacity-40">
Payment Status
</div>
<Element_ae_obj_field_editor <Element_ae_obj_field_editor
object_type="event_exhibit" object_type="event_exhibit"
object_id={exhibit_id} object_id={exhibit_id}
field_name="priority" field_name="priority"
field_type="checkbox" field_type="checkbox"
current_value={$lq__exhibit_obj?.priority} current_value={$lq__exhibit_obj?.priority}
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })} on_success={() =>
> events_func.load_ae_obj_id__event_exhibit({
<div class="font-bold">{$lq__exhibit_obj?.priority ? 'PAID' : 'PENDING'}</div> api_cfg: $ae_api,
exhibit_id
})}>
<div class="font-bold">
{$lq__exhibit_obj?.priority ? 'PAID' : 'PENDING'}
</div>
</Element_ae_obj_field_editor> </Element_ae_obj_field_editor>
</div> </div>
<!-- Max Licenses --> <!-- Max Licenses -->
<div class="card p-3 preset-tonal-surface flex items-center justify-between"> <div
<div class="text-[10px] uppercase font-black opacity-40">Max Licenses</div> class="card preset-tonal-surface flex items-center justify-between p-3">
<div class="text-[10px] font-black uppercase opacity-40">
Max Licenses
</div>
<Element_ae_obj_field_editor <Element_ae_obj_field_editor
object_type="event_exhibit" object_type="event_exhibit"
object_id={exhibit_id} object_id={exhibit_id}
@@ -76,13 +108,19 @@
field_type="number" field_type="number"
current_value={$lq__exhibit_obj?.license_max} current_value={$lq__exhibit_obj?.license_max}
class_li="font-mono" class_li="font-mono"
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })} on_success={() =>
/> events_func.load_ae_obj_id__event_exhibit({
api_cfg: $ae_api,
exhibit_id
})} />
</div> </div>
<!-- Small Devices --> <!-- Small Devices -->
<div class="card p-3 preset-tonal-surface flex items-center justify-between"> <div
<div class="text-[10px] uppercase font-black opacity-40">Small Devices</div> class="card preset-tonal-surface flex items-center justify-between p-3">
<div class="text-[10px] font-black uppercase opacity-40">
Small Devices
</div>
<Element_ae_obj_field_editor <Element_ae_obj_field_editor
object_type="event_exhibit" object_type="event_exhibit"
object_id={exhibit_id} object_id={exhibit_id}
@@ -90,13 +128,19 @@
field_type="number" field_type="number"
current_value={$lq__exhibit_obj?.leads_device_sm_qty} current_value={$lq__exhibit_obj?.leads_device_sm_qty}
class_li="font-mono" class_li="font-mono"
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })} on_success={() =>
/> events_func.load_ae_obj_id__event_exhibit({
api_cfg: $ae_api,
exhibit_id
})} />
</div> </div>
<!-- Large Devices --> <!-- Large Devices -->
<div class="card p-3 preset-tonal-surface flex items-center justify-between"> <div
<div class="text-[10px] uppercase font-black opacity-40">Large Devices</div> class="card preset-tonal-surface flex items-center justify-between p-3">
<div class="text-[10px] font-black uppercase opacity-40">
Large Devices
</div>
<Element_ae_obj_field_editor <Element_ae_obj_field_editor
object_type="event_exhibit" object_type="event_exhibit"
object_id={exhibit_id} object_id={exhibit_id}
@@ -104,8 +148,11 @@
field_type="number" field_type="number"
current_value={$lq__exhibit_obj?.leads_device_lg_qty} current_value={$lq__exhibit_obj?.leads_device_lg_qty}
class_li="font-mono" class_li="font-mono"
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })} on_success={() =>
/> events_func.load_ae_obj_id__event_exhibit({
api_cfg: $ae_api,
exhibit_id
})} />
</div> </div>
</div> </div>
</section> </section>
@@ -113,16 +160,21 @@
<!-- Section: Booth Profile --> <!-- Section: Booth Profile -->
<section class="space-y-4"> <section class="space-y-4">
<div class="flex items-center gap-2 border-b border-surface-500/10 pb-2"> <div
class="border-surface-500/10 flex items-center gap-2 border-b pb-2">
<Store size="1.2em" class="text-primary-500" /> <Store size="1.2em" class="text-primary-500" />
<h3 class="text-lg font-bold uppercase tracking-wider">Booth Profile</h3> <h3 class="text-lg font-bold tracking-wider uppercase">
Booth Profile
</h3>
</div> </div>
<div class="grid grid-cols-1 gap-6"> <div class="grid grid-cols-1 gap-6">
<!-- Name --> <!-- Name -->
<div class="card p-4 preset-tonal-surface shadow-sm"> <div class="card preset-tonal-surface p-4 shadow-sm">
<div class="label mb-2"> <div class="label mb-2">
<span class="text-xs uppercase font-black opacity-40 tracking-widest">Exhibitor Name</span> <span
class="text-xs font-black tracking-widest uppercase opacity-40"
>Exhibitor Name</span>
</div> </div>
<Element_ae_obj_field_editor <Element_ae_obj_field_editor
object_type="event_exhibit" object_type="event_exhibit"
@@ -132,15 +184,23 @@
current_value={$lq__exhibit_obj?.name} current_value={$lq__exhibit_obj?.name}
display_block={true} display_block={true}
class_li="font-bold text-xl" class_li="font-bold text-xl"
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })} on_success={() =>
/> events_func.load_ae_obj_id__event_exhibit({
<p class="text-[10px] opacity-50 mt-2 italic">This name is visible to attendees when you scan their badges.</p> api_cfg: $ae_api,
exhibit_id
})} />
<p class="mt-2 text-[10px] italic opacity-50">
This name is visible to attendees when you scan their
badges.
</p>
</div> </div>
<!-- Description --> <!-- Description -->
<div class="card p-4 preset-tonal-surface shadow-sm"> <div class="card preset-tonal-surface p-4 shadow-sm">
<div class="label mb-2"> <div class="label mb-2">
<span class="text-xs uppercase font-black opacity-40 tracking-widest">Booth Description / Promo</span> <span
class="text-xs font-black tracking-widest uppercase opacity-40"
>Booth Description / Promo</span>
</div> </div>
<Element_ae_obj_field_editor <Element_ae_obj_field_editor
object_type="event_exhibit" object_type="event_exhibit"
@@ -150,29 +210,39 @@
current_value={$lq__exhibit_obj?.description} current_value={$lq__exhibit_obj?.description}
display_block={true} display_block={true}
class_li="text-sm" class_li="text-sm"
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })} on_success={() =>
/> events_func.load_ae_obj_id__event_exhibit({
api_cfg: $ae_api,
exhibit_id
})} />
</div> </div>
</div> </div>
</section> </section>
<!-- Section: Staff Access --> <!-- Section: Staff Access -->
<section class="space-y-4"> <section class="space-y-4">
<div class="flex items-center gap-2 border-b border-surface-500/10 pb-2"> <div
class="border-surface-500/10 flex items-center gap-2 border-b pb-2">
<Lock size="1.2em" class="text-warning-500" /> <Lock size="1.2em" class="text-warning-500" />
<h3 class="text-lg font-bold uppercase tracking-wider">Access & Security</h3> <h3 class="text-lg font-bold tracking-wider uppercase">
Access & Security
</h3>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Staff Passcode --> <!-- Staff Passcode -->
<div class="card p-4 bg-surface-500/5 border border-surface-500/10"> <div class="card bg-surface-500/5 border-surface-500/10 border p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex-1"> <div class="flex-1">
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest mb-1">Staff Passcode</div> <div
class="mb-1 text-[10px] font-black tracking-widest uppercase opacity-40">
Staff Passcode
</div>
<!-- Add a clear read-only display for admins to see the code at a glance --> <!-- Add a clear read-only display for admins to see the code at a glance -->
{#if $ae_loc.administrator_access} {#if $ae_loc.administrator_access}
<div class="font-mono text-xl tracking-widest font-bold text-primary-500 mb-2"> <div
class="text-primary-500 mb-2 font-mono text-xl font-bold tracking-widest">
{$lq__exhibit_obj?.staff_passcode || '----'} {$lq__exhibit_obj?.staff_passcode || '----'}
</div> </div>
{/if} {/if}
@@ -185,33 +255,44 @@
current_value={$lq__exhibit_obj?.staff_passcode} current_value={$lq__exhibit_obj?.staff_passcode}
display_block={true} display_block={true}
class_li="font-mono text-xl tracking-widest font-bold" class_li="font-mono text-xl tracking-widest font-bold"
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })} on_success={() =>
/> events_func.load_ae_obj_id__event_exhibit({
api_cfg: $ae_api,
exhibit_id
})} />
</div> </div>
<Key size="1.5em" class="opacity-20" /> <Key size="1.5em" class="opacity-20" />
</div> </div>
<p class="text-[9px] opacity-40 mt-2 italic">Shared code for your team to sign in to this booth.</p> <p class="mt-2 text-[9px] italic opacity-40">
Shared code for your team to sign in to this booth.
</p>
</div> </div>
<!-- Booth Code --> <!-- Booth Code -->
<div class="card p-4 bg-surface-500/5 border border-surface-500/10"> <div class="card bg-surface-500/5 border-surface-500/10 border p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest">Booth Identifier</div> <div
<div class="font-mono text-xl font-bold">#{$lq__exhibit_obj?.code || 'N/A'}</div> class="text-[10px] font-black tracking-widest uppercase opacity-40">
Booth Identifier
</div>
<div class="font-mono text-xl font-bold">
#{$lq__exhibit_obj?.code || 'N/A'}
</div>
</div> </div>
<Info size="1.5em" class="opacity-20" /> <Info size="1.5em" class="opacity-20" />
</div> </div>
<p class="text-[9px] opacity-40 mt-2 italic">Official floor plan booth number.</p> <p class="mt-2 text-[9px] italic opacity-40">
Official floor plan booth number.
</p>
</div> </div>
</div> </div>
<!-- Sign Out --> <!-- Sign Out -->
{#if !$ae_loc.manager_access} {#if !$ae_loc.manager_access}
<button <button
class="btn preset-outlined-error w-full mt-2" class="btn preset-outlined-error mt-2 w-full"
onclick={handle_signout} onclick={handle_signout}>
>
<LogOut size="1.2em" class="mr-2" /> Sign Out of Booth <LogOut size="1.2em" class="mr-2" /> Sign Out of Booth
</button> </button>
{/if} {/if}
@@ -219,43 +300,57 @@
<!-- Section: Lead Settings --> <!-- Section: Lead Settings -->
<section class="space-y-4"> <section class="space-y-4">
<div class="flex items-center gap-2 border-b border-surface-500/10 pb-2"> <div
class="border-surface-500/10 flex items-center gap-2 border-b pb-2">
<Settings size="1.2em" class="text-secondary-500" /> <Settings size="1.2em" class="text-secondary-500" />
<h3 class="text-lg font-bold uppercase tracking-wider">Lead Retrieval Config</h3> <h3 class="text-lg font-bold tracking-wider uppercase">
Lead Retrieval Config
</h3>
</div> </div>
<div class="card p-0 divide-y divide-surface-500/10 overflow-hidden shadow-md"> <div
class="card divide-surface-500/10 divide-y overflow-hidden p-0 shadow-md">
<!-- Licenses — visible to: Aether admins OR someone signed in with the shared exhibit passcode. <!-- Licenses — visible to: Aether admins OR someone signed in with the shared exhibit passcode.
Spec: "A client staff (Trusted Access or above) or someone signed in with an Exhibit passcode Spec: "A client staff (Trusted Access or above) or someone signed in with an Exhibit passcode
can add/edit/remove licenses." — PROJECT__AE_Events_Exhibitor_Leads_v3.md --> can add/edit/remove licenses." — PROJECT__AE_Events_Exhibitor_Leads_v3.md -->
{#if $ae_loc.administrator_access || $events_loc.leads.auth_exhibit_kv?.[exhibit_id]?.type === 'shared'} {#if $ae_loc.administrator_access || $events_loc.leads.auth_exhibit_kv?.[exhibit_id]?.type === 'shared'}
<div class="p-0"> <div class="p-0">
<button <button
class="w-full p-4 flex items-center justify-between hover:bg-surface-500/5 transition-colors group" class="hover:bg-surface-500/5 group flex w-full items-center justify-between p-4 transition-colors"
onclick={() => show_license_mgmt = !show_license_mgmt} onclick={() =>
> (show_license_mgmt = !show_license_mgmt)}>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="bg-primary-500/10 p-2 rounded-lg text-primary-500"><Users size="1.2em" /></div> <div
class="bg-primary-500/10 text-primary-500 rounded-lg p-2">
<Users size="1.2em" />
</div>
<div class="text-left"> <div class="text-left">
<div class="font-bold text-sm">Exhibit Leads Licensees</div> <div class="text-sm font-bold">
<div class="text-xs opacity-50">Manage assigned users and codes</div> Exhibit Leads Licensees
</div>
<div class="text-xs opacity-50">
Manage assigned users and codes
</div>
</div> </div>
</div> </div>
{#if show_license_mgmt} {#if show_license_mgmt}
<ChevronDown size="1.2em" class="opacity-20" /> <ChevronDown size="1.2em" class="opacity-20" />
{:else} {:else}
<ChevronRight size="1.2em" class="opacity-20 group-hover:translate-x-1 transition-transform" /> <ChevronRight
size="1.2em"
class="opacity-20 transition-transform group-hover:translate-x-1" />
{/if} {/if}
</button> </button>
{#if show_license_mgmt} {#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"> <div
class="bg-surface-500/5 border-surface-500/10 animate-in fade-in slide-in-from-top-2 border-t p-4">
<Comp_exhibit_license_list <Comp_exhibit_license_list
{exhibit_id} {exhibit_id}
event_id={page.params.event_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} />
</div> </div>
{/if} {/if}
</div> </div>
@@ -264,30 +359,40 @@
<!-- Custom Questions --> <!-- Custom Questions -->
<div class="p-0"> <div class="p-0">
<button <button
class="w-full p-4 flex items-center justify-between hover:bg-surface-500/5 transition-colors group" class="hover:bg-surface-500/5 group flex w-full items-center justify-between p-4 transition-colors"
onclick={() => show_custom_questions = !show_custom_questions} onclick={() =>
> (show_custom_questions = !show_custom_questions)}>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="bg-secondary-500/10 p-2 rounded-lg text-secondary-500"><MessageSquare size="1.2em" /></div> <div
class="bg-secondary-500/10 text-secondary-500 rounded-lg p-2">
<MessageSquare size="1.2em" />
</div>
<div class="text-left"> <div class="text-left">
<div class="font-bold text-sm">Qualifiers & Questions</div> <div class="text-sm font-bold">
<div class="text-xs opacity-50">Configure lead capture follow-up responses</div> Qualifiers & Questions
</div>
<div class="text-xs opacity-50">
Configure lead capture follow-up responses
</div>
</div> </div>
</div> </div>
{#if show_custom_questions} {#if show_custom_questions}
<ChevronDown size="1.2em" class="opacity-20" /> <ChevronDown size="1.2em" class="opacity-20" />
{:else} {:else}
<ChevronRight size="1.2em" class="opacity-20 group-hover:translate-x-1 transition-transform" /> <ChevronRight
size="1.2em"
class="opacity-20 transition-transform group-hover:translate-x-1" />
{/if} {/if}
</button> </button>
{#if show_custom_questions} {#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"> <div
class="bg-surface-500/5 border-surface-500/10 animate-in fade-in slide-in-from-top-2 border-t p-4">
<Comp_exhibit_custom_questions <Comp_exhibit_custom_questions
{exhibit_id} {exhibit_id}
event_id={page.params.event_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>
{/if} {/if}
</div> </div>
@@ -295,25 +400,34 @@
<!-- Billing --> <!-- Billing -->
<div class="p-0"> <div class="p-0">
<button <button
class="w-full p-4 flex items-center justify-between hover:bg-surface-500/5 transition-colors group" class="hover:bg-surface-500/5 group flex w-full items-center justify-between p-4 transition-colors"
onclick={() => show_billing = !show_billing} onclick={() => (show_billing = !show_billing)}>
>
<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 text-success-500 rounded-lg p-2">
<CreditCard size="1.2em" />
</div>
<div class="text-left"> <div class="text-left">
<div class="font-bold text-sm">Licenses & Billing</div> <div class="text-sm font-bold">
<div class="text-xs opacity-50">Review licenses and manage payment</div> Licenses & Billing
</div>
<div class="text-xs opacity-50">
Review licenses and manage payment
</div>
</div> </div>
</div> </div>
{#if show_billing} {#if show_billing}
<ChevronDown size="1.2em" class="opacity-20" /> <ChevronDown size="1.2em" class="opacity-20" />
{:else} {:else}
<ChevronRight size="1.2em" class="opacity-20 group-hover:translate-x-1 transition-transform" /> <ChevronRight
size="1.2em"
class="opacity-20 transition-transform group-hover:translate-x-1" />
{/if} {/if}
</button> </button>
{#if show_billing} {#if show_billing}
<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="bg-surface-500/5 border-surface-500/10 animate-in fade-in slide-in-from-top-2 border-t p-4">
<Comp_exhibit_payment /> <Comp_exhibit_payment />
</div> </div>
{/if} {/if}
@@ -323,28 +437,46 @@
<!-- Section: App Settings --> <!-- Section: App Settings -->
<section class="space-y-4"> <section class="space-y-4">
<div class="flex items-center gap-2 border-b border-surface-500/10 pb-2"> <div
class="border-surface-500/10 flex items-center gap-2 border-b pb-2">
<Settings size="1.2em" class="text-secondary-500" /> <Settings size="1.2em" class="text-secondary-500" />
<h3 class="text-lg font-bold uppercase tracking-wider">App Settings</h3> <h3 class="text-lg font-bold tracking-wider uppercase">
App Settings
</h3>
</div> </div>
<div class="card p-4 space-y-6 preset-tonal-surface shadow-inner"> <div class="card preset-tonal-surface space-y-6 p-4 shadow-inner">
<!-- Interface Prefs --> <!-- Interface Prefs -->
<div class="space-y-3"> <div class="space-y-3">
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest">Interface Preferences</div> <div
class="text-[10px] font-black tracking-widest uppercase opacity-40">
Interface Preferences
</div>
<div class="grid grid-cols-1 gap-2"> <div class="grid grid-cols-1 gap-2">
<label class="flex items-center justify-between p-2 hover:bg-surface-500/10 rounded-lg cursor-pointer transition-colors"> <label
class="hover:bg-surface-500/10 flex cursor-pointer items-center justify-between rounded-lg p-2 transition-colors">
<span class="text-sm">Auto-hide Header/Footer</span> <span class="text-sm">Auto-hide Header/Footer</span>
<input type="checkbox" class="checkbox" bind:checked={$ae_loc.auto_hide_nav} /> <input
type="checkbox"
class="checkbox"
bind:checked={$ae_loc.auto_hide_nav} />
</label> </label>
<label class="flex items-center justify-between p-2 hover:bg-surface-500/10 rounded-lg cursor-pointer transition-colors"> <label
class="hover:bg-surface-500/10 flex cursor-pointer items-center justify-between rounded-lg p-2 transition-colors">
<span class="text-sm">Show Payment Tab</span> <span class="text-sm">Show Payment Tab</span>
<input type="checkbox" class="checkbox" bind:checked={$ae_loc.show_leads_payment} /> <input
type="checkbox"
class="checkbox"
bind:checked={$ae_loc.show_leads_payment} />
</label> </label>
<label class="flex items-center justify-between p-2 hover:bg-surface-500/10 rounded-lg cursor-pointer transition-colors"> <label
class="hover:bg-surface-500/10 flex cursor-pointer items-center justify-between rounded-lg p-2 transition-colors">
<span class="text-sm">Show Extra Details</span> <span class="text-sm">Show Extra Details</span>
<input type="checkbox" class="checkbox" bind:checked={$events_loc.show_details} /> <input
type="checkbox"
class="checkbox"
bind:checked={$events_loc.show_details} />
</label> </label>
</div> </div>
</div> </div>
@@ -352,56 +484,81 @@
<!-- List Refresh --> <!-- List Refresh -->
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest">Data Synchronization</div> <div
<div class="flex items-center gap-2 text-[10px] font-mono opacity-60"> class="text-[10px] font-black tracking-widest uppercase opacity-40">
Data Synchronization
</div>
<div
class="flex items-center gap-2 font-mono text-[10px] opacity-60">
<Clock size="1em" /> <Clock size="1em" />
{#if $events_sess.leads.last_refresh_time} {#if $events_sess.leads.last_refresh_time}
Last: {new Date($events_sess.leads.last_refresh_time).toLocaleTimeString()} Last: {new Date(
$events_sess.leads.last_refresh_time
).toLocaleTimeString()}
{:else} {:else}
Waiting... Waiting...
{/if} {/if}
</div> </div>
</div> </div>
<div class="flex items-center gap-4 p-2 bg-surface-500/5 rounded-lg border border-surface-500/10"> <div
class="bg-surface-500/5 border-surface-500/10 flex items-center gap-4 rounded-lg border p-2">
<div class="flex-1 space-y-1"> <div class="flex-1 space-y-1">
<span class="text-sm block">Refresh Interval (sec)</span> <span class="block text-sm"
<div class="text-[9px] opacity-40 uppercase font-bold"> >Refresh Interval (sec)</span>
Next Sync in <span class="text-primary-500">{$events_sess.leads.next_refresh_countdown}s</span> <div class="text-[9px] font-bold uppercase opacity-40">
Next Sync in <span class="text-primary-500"
>{$events_sess.leads
.next_refresh_countdown}s</span>
</div> </div>
</div> </div>
<input <input
type="number" type="number"
class="input w-20 text-right font-mono p-1 bg-transparent border-b border-surface-500/20" class="input border-surface-500/20 w-20 border-b bg-transparent p-1 text-right font-mono"
min="1" min="1"
max="120" max="120"
bind:value={$events_loc.leads.refresh_interval_sec} bind:value={$events_loc.leads.refresh_interval_sec}
placeholder="25" placeholder="25" />
/>
</div> </div>
</div> </div>
<!-- Maintenance --> <!-- Maintenance -->
<div class="space-y-3"> <div class="space-y-3">
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest">Maintenance & Reset</div> <div
class="text-[10px] font-black tracking-widest uppercase opacity-40">
Maintenance & Reset
</div>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<button class="btn btn-sm preset-filled-warning" onclick={() => window.location.reload()}> <button
class="btn btn-sm preset-filled-warning"
onclick={() => window.location.reload()}>
<RefreshCw size="1em" class="mr-2" /> Reload App <RefreshCw size="1em" class="mr-2" /> Reload App
</button> </button>
<button class="btn btn-sm preset-outlined-error" onclick={() => { <button
if(confirm('Clear all local cached data (IDB)?')) { class="btn btn-sm preset-outlined-error"
db_events.delete().then(() => window.location.reload()); onclick={() => {
} if (confirm('Clear all local cached data (IDB)?')) {
}}> db_events
.delete()
.then(() => window.location.reload());
}
}}>
<Database size="1em" class="mr-2" /> Clear IDB <Database size="1em" class="mr-2" /> Clear IDB
</button> </button>
<button class="btn btn-sm preset-outlined-error col-span-2" onclick={() => { <button
if(confirm('Reset all local app settings and sign out?')) { class="btn btn-sm preset-outlined-error col-span-2"
localStorage.clear(); onclick={() => {
window.location.reload(); if (
} confirm(
}}> 'Reset all local app settings and sign out?'
<UserX size="1em" class="mr-2" /> Clear Local Settings (Hard Reset) )
) {
localStorage.clear();
window.location.reload();
}
}}>
<UserX size="1em" class="mr-2" /> Clear Local Settings (Hard
Reset)
</button> </button>
</div> </div>
</div> </div>
@@ -409,13 +566,14 @@
</section> </section>
<!-- Help Footer --> <!-- Help Footer -->
<div class="pt-10 pb-20 text-center space-y-2 opacity-40"> <div class="space-y-2 pt-10 pb-20 text-center opacity-40">
<p class="text-xs">Exhibitor Management Module v3.0</p> <p class="text-xs">Exhibitor Management Module v3.0</p>
<p class="text-[10px] font-mono">Exhibit ID: {$lq__exhibit_obj?.event_exhibit_id}</p> <p class="font-mono text-[10px]">
Exhibit ID: {$lq__exhibit_obj?.event_exhibit_id}
</p>
</div> </div>
</div> </div>
<style lang="postcss"> <style lang="postcss">
/* Custom tab styles if needed */ /* Custom tab styles if needed */
</style> </style>

View File

@@ -1,69 +1,85 @@
<script lang="ts"> <script lang="ts">
/** /**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__start.svelte * src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__start.svelte
* Tab 1: Start / Sign In / Welcome. * Tab 1: Start / Sign In / Welcome.
*/ */
import { page } from '$app/state'; import { page } from '$app/state';
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 Comp_exhibit_signin from './ae_comp__exhibit_signin.svelte'; import Comp_exhibit_signin from './ae_comp__exhibit_signin.svelte';
import Element_pwa_install_prompt from '$lib/elements/element_pwa_install_prompt.svelte'; import Element_pwa_install_prompt from '$lib/elements/element_pwa_install_prompt.svelte';
import { CircleCheck, LayoutGrid, ShieldCheck, UserCheck } from '@lucide/svelte'; import {
const exhibit_id = $derived(page.params.exhibit_id ?? ''); CircleCheck,
LayoutGrid,
ShieldCheck,
UserCheck
} from '@lucide/svelte';
const exhibit_id = $derived(page.params.exhibit_id ?? '');
let lq__exhibit_obj = $derived( let lq__exhibit_obj = $derived(
liveQuery(async () => { liveQuery(async () => {
if (!exhibit_id) return null; if (!exhibit_id) return null;
return await db_events.exhibit.get(exhibit_id); return await db_events.exhibit.get(exhibit_id);
}) })
); );
</script> </script>
<div class="ae-tab-start w-full space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-300"> <div
class="ae-tab-start animate-in fade-in slide-in-from-bottom-2 w-full space-y-8 duration-300">
<!-- Hero / Welcome Section --> <!-- Hero / Welcome Section -->
<section class="text-center space-y-4 py-6"> <section class="space-y-4 py-6 text-center">
<div class="inline-flex p-4 rounded-full bg-primary-500/10 text-primary-500 mb-2"> <div
class="bg-primary-500/10 text-primary-500 mb-2 inline-flex rounded-full p-4">
<LayoutGrid size="3em" /> <LayoutGrid size="3em" />
</div> </div>
<h2 class="text-3xl font-black tracking-tight"> <h2 class="text-3xl font-black tracking-tight">
Welcome to the<br /> Welcome to the<br />
<span class="text-primary-500">Exhibitor Portal</span> <span class="text-primary-500">Exhibitor Portal</span>
</h2> </h2>
<p class="text-lg opacity-60 max-w-md mx-auto"> <p class="mx-auto max-w-md text-lg opacity-60">
Ready to capture leads for <span class="font-bold text-surface-900-100">{$lq__exhibit_obj?.name || 'this exhibit'}</span>? Ready to capture leads for <span
class="text-surface-900-100 font-bold"
>{$lq__exhibit_obj?.name || 'this exhibit'}</span
>?
</p> </p>
</section> </section>
<!-- Features Grid (Compact) --> <!-- Features Grid (Compact) -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 max-w-2xl mx-auto px-4"> <div class="mx-auto grid max-w-2xl grid-cols-1 gap-4 px-4 sm:grid-cols-3">
<div class="flex flex-col items-center text-center p-4 rounded-xl preset-tonal-surface"> <div
class="preset-tonal-surface flex flex-col items-center rounded-xl p-4 text-center">
<CircleCheck size="1.5em" class="text-success-500 mb-2" /> <CircleCheck size="1.5em" class="text-success-500 mb-2" />
<span class="text-xs font-bold uppercase tracking-wider">Fast Capture</span> <span class="text-xs font-bold tracking-wider uppercase"
>Fast Capture</span>
</div> </div>
<div class="flex flex-col items-center text-center p-4 rounded-xl preset-tonal-surface"> <div
class="preset-tonal-surface flex flex-col items-center rounded-xl p-4 text-center">
<UserCheck size="1.5em" class="text-secondary-500 mb-2" /> <UserCheck size="1.5em" class="text-secondary-500 mb-2" />
<span class="text-xs font-bold uppercase tracking-wider">Staff IDs</span> <span class="text-xs font-bold tracking-wider uppercase"
>Staff IDs</span>
</div> </div>
<div class="flex flex-col items-center text-center p-4 rounded-xl preset-tonal-surface"> <div
class="preset-tonal-surface flex flex-col items-center rounded-xl p-4 text-center">
<ShieldCheck size="1.5em" class="text-primary-500 mb-2" /> <ShieldCheck size="1.5em" class="text-primary-500 mb-2" />
<span class="text-xs font-bold uppercase tracking-wider">Secure Sync</span> <span class="text-xs font-bold tracking-wider uppercase"
>Secure Sync</span>
</div> </div>
</div> </div>
<!-- PWA Install Nudge — shown between feature highlights and sign-in --> <!-- PWA Install Nudge — shown between feature highlights and sign-in -->
<div class="w-full max-w-md mx-auto px-4"> <div class="mx-auto w-full max-w-md px-4">
<Element_pwa_install_prompt /> <Element_pwa_install_prompt />
</div> </div>
<!-- Sign In Area --> <!-- Sign In Area -->
<div class="w-full max-w-md mx-auto"> <div class="mx-auto w-full max-w-md">
<Comp_exhibit_signin /> <Comp_exhibit_signin />
</div> </div>
<!-- Info Footer --> <!-- Info Footer -->
<div class="text-center pt-8 opacity-40"> <div class="pt-8 text-center opacity-40">
<p class="text-[10px] uppercase font-black tracking-[0.2em]">Powered by Aether Platform</p> <p class="text-[10px] font-black tracking-[0.2em] uppercase">
Powered by Aether Platform
</p>
</div> </div>
</div> </div>

View File

@@ -1,98 +1,117 @@
<script lang="ts"> <script lang="ts">
/** /**
* src/routes/events/[event_id]/(leads)/leads/lead/[exhibit_tracking_id]/+page.svelte * src/routes/events/[event_id]/(leads)/leads/lead/[exhibit_tracking_id]/+page.svelte
* Lead Detail View - Basic Read-Only version. * Lead Detail View - Basic Read-Only version.
*/ */
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; 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_util } from '$lib/ae_utils/ae_utils'; import { ae_util } from '$lib/ae_utils/ae_utils';
import { ae_api, ae_loc } from '$lib/stores/ae_stores'; import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events/ae_events_functions'; import { events_func } from '$lib/ae_events/ae_events_functions';
import Element_ae_obj_field_editor from '$lib/elements/element_ae_obj_field_editor.svelte'; import Element_ae_obj_field_editor from '$lib/elements/element_ae_obj_field_editor.svelte';
import Comp_lead_detail_form from './ae_comp__lead_detail_form.svelte'; import Comp_lead_detail_form from './ae_comp__lead_detail_form.svelte';
import { Briefcase, CalendarDays, ChevronLeft, Eye, FileText, ListTodo, LoaderCircle, Mail, MapPin, RotateCcw, ShieldCheck, SquarePen, Star, Store, Trash2, User } from '@lucide/svelte'; import {
const exhibit_tracking_id = $derived(page.params.exhibit_tracking_id); Briefcase,
CalendarDays,
ChevronLeft,
Eye,
FileText,
ListTodo,
LoaderCircle,
Mail,
MapPin,
RotateCcw,
ShieldCheck,
SquarePen,
Star,
Store,
Trash2,
User
} from '@lucide/svelte';
const exhibit_tracking_id = $derived(page.params.exhibit_tracking_id);
let lq__lead_obj = $derived( let lq__lead_obj = $derived(
liveQuery(async () => { liveQuery(async () => {
if (!exhibit_tracking_id) return null; if (!exhibit_tracking_id) return null;
return await db_events.exhibit_tracking.get(exhibit_tracking_id); return await db_events.exhibit_tracking.get(exhibit_tracking_id);
}) })
); );
let lq__exhibit_obj = $derived( let lq__exhibit_obj = $derived(
liveQuery(async () => { liveQuery(async () => {
const exhibit_id = page.params.exhibit_id; const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return null; if (!exhibit_id) return null;
return await db_events.exhibit.get(exhibit_id); return await db_events.exhibit.get(exhibit_id);
}) })
); );
let is_edit_mode = $state(false); let is_edit_mode = $state(false);
// Remove / Restore flow. // Remove / Restore flow.
// Two-click confirm for remove: idle → confirm → removing → (navigate back). // Two-click confirm for remove: idle → confirm → removing → (navigate back).
let remove_status = $state<'idle' | 'confirm' | 'removing' | 'restoring'>('idle'); let remove_status = $state<'idle' | 'confirm' | 'removing' | 'restoring'>(
'idle'
);
async function remove_lead() { async function remove_lead() {
const eid = page.params.exhibit_id ?? ''; const eid = page.params.exhibit_id ?? '';
if (!exhibit_tracking_id || !eid) return; if (!exhibit_tracking_id || !eid) return;
remove_status = 'removing'; remove_status = 'removing';
try { try {
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: eid, exhibit_id: eid,
exhibit_tracking_id, exhibit_tracking_id,
data: { enable: false } data: { enable: false }
}); });
// Navigate back to exhibit leads list after removal // Navigate back to exhibit leads list after removal
goto(`/events/${page.params.event_id}/leads/exhibit/${eid}`); goto(`/events/${page.params.event_id}/leads/exhibit/${eid}`);
} catch { } catch {
// If update fails, reset so user can try again // If update fails, reset so user can try again
remove_status = 'idle'; remove_status = 'idle';
}
} }
}
async function restore_lead() { async function restore_lead() {
const eid = page.params.exhibit_id ?? ''; const eid = page.params.exhibit_id ?? '';
if (!exhibit_tracking_id || !eid) return; if (!exhibit_tracking_id || !eid) return;
remove_status = 'restoring'; remove_status = 'restoring';
try { try {
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: eid, exhibit_id: eid,
exhibit_tracking_id, exhibit_tracking_id,
data: { enable: true } data: { enable: true }
}); });
remove_status = 'idle'; remove_status = 'idle';
} catch { } catch {
remove_status = 'idle'; remove_status = 'idle';
}
} }
}
// Helper to format date using Aether utility // Helper to format date using Aether utility
function format_date(date: any) { function format_date(date: any) {
if (!date) return ''; if (!date) return '';
return ae_util.iso_datetime_formatter(date, 'datetime_iso_12_no_seconds'); return ae_util.iso_datetime_formatter(date, 'datetime_iso_12_no_seconds');
} }
</script> </script>
<svelte:head> <svelte:head>
<title>Lead: {$lq__lead_obj?.event_badge_full_name ?? 'Loading...'}</title> <title>Lead: {$lq__lead_obj?.event_badge_full_name ?? 'Loading...'}</title>
</svelte:head> </svelte:head>
<section class="lead-detail-view w-full flex flex-col items-center"> <section class="lead-detail-view flex w-full flex-col items-center">
<!-- Local Header --> <!-- 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"> <header
class="bg-surface-100-900 border-surface-500/20 sticky top-0 z-10 flex w-full items-center justify-between gap-4 border-b px-4 py-3 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/${page.params.exhibit_id}`} href={`/events/${page.params.event_id}/leads/exhibit/${page.params.exhibit_id}`}
class="btn btn-sm preset-outlined-surface" class="btn btn-sm preset-outlined-surface">
>
<ChevronLeft size="1.2em" /> <ChevronLeft size="1.2em" />
<span class="hidden sm:inline ml-1">Back</span> <span class="ml-1 hidden sm:inline">Back</span>
</a> </a>
<h1 class="text-lg font-bold">Lead Profile</h1> <h1 class="text-lg font-bold">Lead Profile</h1>
</div> </div>
@@ -103,8 +122,10 @@
class="btn btn-sm" class="btn btn-sm"
class:preset-filled-primary={is_edit_mode} class:preset-filled-primary={is_edit_mode}
class:preset-outlined-surface={!is_edit_mode} class:preset-outlined-surface={!is_edit_mode}
onclick={() => { is_edit_mode = !is_edit_mode; remove_status = 'idle'; }} onclick={() => {
> is_edit_mode = !is_edit_mode;
remove_status = 'idle';
}}>
{#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}
@@ -119,23 +140,21 @@
<button <button
type="button" type="button"
class="btn btn-sm preset-filled-error font-bold" class="btn btn-sm preset-filled-error font-bold"
onclick={remove_lead} onclick={remove_lead}>
>
<Trash2 size="1em" /> <Trash2 size="1em" />
Confirm Remove? Confirm Remove?
</button> </button>
<button <button
type="button" type="button"
class="btn btn-sm preset-outlined-surface opacity-60" class="btn btn-sm preset-outlined-surface opacity-60"
onclick={() => remove_status = 'idle'} onclick={() => (remove_status = 'idle')}
>Cancel</button> >Cancel</button>
{:else} {:else}
<button <button
type="button" type="button"
class="btn btn-sm preset-outlined-error opacity-70" class="btn btn-sm preset-outlined-error opacity-70"
disabled={remove_status === 'removing'} disabled={remove_status === 'removing'}
onclick={() => remove_status = 'confirm'} onclick={() => (remove_status = 'confirm')}>
>
{#if remove_status === 'removing'} {#if remove_status === 'removing'}
<LoaderCircle size="1em" class="animate-spin" /> <LoaderCircle size="1em" class="animate-spin" />
{:else} {:else}
@@ -148,7 +167,8 @@
{/if} {/if}
{#if $lq__lead_obj?.priority} {#if $lq__lead_obj?.priority}
<span class="badge preset-filled-warning font-bold flex items-center gap-1"> <span
class="badge preset-filled-warning flex items-center gap-1 font-bold">
<Star size="1em" fill="currentColor" /> <Star size="1em" fill="currentColor" />
Priority Priority
</span> </span>
@@ -156,94 +176,143 @@
</div> </div>
</header> </header>
<div class="w-full max-w-5xl p-4 sm:p-6 space-y-6"> <div class="w-full max-w-5xl space-y-6 p-4 sm:p-6">
{#if !$lq__lead_obj} {#if !$lq__lead_obj}
<div class="flex flex-col items-center justify-center p-20 opacity-50 text-center"> <div
<LoaderCircle size="3em" class="animate-spin mb-4 mx-auto" /> class="flex flex-col items-center justify-center p-20 text-center opacity-50">
<LoaderCircle size="3em" class="mx-auto mb-4 animate-spin" />
<p class="text-xl">Loading lead details...</p> <p class="text-xl">Loading lead details...</p>
</div> </div>
{:else} {:else}
<!-- Main Grid --> <!-- Main Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
<!-- Left: Profile Info --> <!-- Left: Profile Info -->
<div class="lg:col-span-2 space-y-6"> <div class="space-y-6 lg:col-span-2">
<!-- Attendee Core Identity --> <!-- Attendee Core Identity -->
<div class="card p-4 preset-tonal-surface shadow-lg border-l-4 border-primary-500 space-y-2"> <div
class="card preset-tonal-surface border-primary-500 space-y-2 border-l-4 p-4 shadow-lg">
<!-- Name row: small inline icon --> <!-- Name row: small inline icon -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<User size="1.4em" class="text-primary-500 flex-none" /> <User
<h2 class="text-2xl font-black leading-tight"> size="1.4em"
{@html $lq__lead_obj.event_badge_full_name || $lq__lead_obj.event_badge_full_name_override || 'Unknown Attendee'} class="text-primary-500 flex-none" />
<h2 class="text-2xl leading-tight font-black">
{@html $lq__lead_obj.event_badge_full_name ||
$lq__lead_obj.event_badge_full_name_override ||
'Unknown Attendee'}
</h2> </h2>
</div> </div>
<!-- Key details — all visible above the fold on mobile --> <!-- Key details — all visible above the fold on mobile -->
<div class="space-y-1.5 pl-1"> <div class="space-y-1.5 pl-1">
{#if $lq__lead_obj.event_badge_professional_title || $lq__lead_obj.event_badge_professional_title_override} {#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"> <div
<Briefcase size="1em" class="flex-none opacity-60" /> class="flex items-center gap-2 text-sm opacity-80">
<span>{@html $lq__lead_obj.event_badge_professional_title || $lq__lead_obj.event_badge_professional_title_override}</span> <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> </div>
{/if} {/if}
{#if $lq__lead_obj.event_badge_affiliations || $lq__lead_obj.event_badge_affiliations_override} {#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"> <div
class="text-primary-500 flex items-center gap-2 text-sm font-semibold">
<MapPin size="1em" class="flex-none" /> <MapPin size="1em" class="flex-none" />
<span>{@html $lq__lead_obj.event_badge_affiliations || $lq__lead_obj.event_badge_affiliations_override}</span> <span
>{@html $lq__lead_obj.event_badge_affiliations ||
$lq__lead_obj.event_badge_affiliations_override}</span>
</div> </div>
{/if} {/if}
<div class="flex items-center gap-2 text-sm opacity-70"> <div
class="flex items-center gap-2 text-sm opacity-70">
<Mail size="1em" class="flex-none" /> <Mail size="1em" class="flex-none" />
<span class="font-mono truncate">{$lq__lead_obj.event_badge_email || 'No email on file'}</span> <span class="truncate font-mono"
>{$lq__lead_obj.event_badge_email ||
'No email on file'}</span>
</div> </div>
<div class="flex items-center gap-2 text-sm opacity-60"> <div
class="flex items-center gap-2 text-sm opacity-60">
<CalendarDays size="1em" class="flex-none" /> <CalendarDays size="1em" class="flex-none" />
<span>Captured {format_date($lq__lead_obj.created_on)}</span> <span
>Captured {format_date(
$lq__lead_obj.created_on
)}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- Custom Responses Section --> <!-- Custom Responses Section -->
<div class="card p-6 space-y-4 shadow-md"> <div class="card space-y-4 p-6 shadow-md">
<div class="flex items-center gap-2 border-b border-surface-500/10 pb-3"> <div
class="border-surface-500/10 flex items-center gap-2 border-b pb-3">
<ListTodo size="1.2em" class="text-primary-500" /> <ListTodo size="1.2em" class="text-primary-500" />
<h3 class="text-lg font-bold uppercase tracking-wider">Custom Responses / Qualifiers</h3> <h3
class="text-lg font-bold tracking-wider uppercase">
Custom Responses / Qualifiers
</h3>
</div> </div>
{#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 ?? ''} 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 ??
'{}'} />
{:else if $lq__lead_obj.responses_json} {:else if $lq__lead_obj.responses_json}
{@const responses = typeof $lq__lead_obj.responses_json === 'string' ? JSON.parse($lq__lead_obj.responses_json) : $lq__lead_obj.responses_json} {@const responses =
typeof $lq__lead_obj.responses_json === 'string'
? JSON.parse($lq__lead_obj.responses_json)
: $lq__lead_obj.responses_json}
{#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="animate-in fade-in grid grid-cols-1 gap-4 sm:grid-cols-2">
{#each Object.entries(responses) as [question, answer] (question)} {#each Object.entries(responses) as [question, answer] (question)}
{@const display_value = (answer !== null && typeof answer === 'object') ? (answer as any).response ?? '' : String(answer ?? '')} {@const display_value =
<div class="p-3 bg-surface-500/5 rounded-lg border border-surface-500/10"> answer !== null &&
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest mb-1 leading-tight">{question}</div> typeof answer === 'object'
<div class="font-semibold text-sm">{display_value || '—'}</div> ? ((answer as any).response ??
'')
: String(answer ?? '')}
<div
class="bg-surface-500/5 border-surface-500/10 rounded-lg border p-3">
<div
class="mb-1 text-[10px] leading-tight font-black tracking-widest uppercase opacity-40">
{question}
</div>
<div class="text-sm font-semibold">
{display_value || '—'}
</div>
</div> </div>
{/each} {/each}
</div> </div>
{:else} {:else}
<p class="text-center opacity-30 italic py-4">No responses captured for this lead.</p> <p class="py-4 text-center italic opacity-30">
No responses captured for this lead.
</p>
{/if} {/if}
{:else} {:else}
<p class="text-center opacity-30 italic py-4">No responses captured for this lead.</p> <p class="py-4 text-center italic opacity-30">
No responses captured for this lead.
</p>
{/if} {/if}
</div> </div>
<!-- Notes Section --> <!-- Notes Section -->
<div class="card p-6 space-y-4 shadow-md"> <div class="card space-y-4 p-6 shadow-md">
<div class="flex items-center gap-2 border-b border-surface-500/10 pb-3"> <div
class="border-surface-500/10 flex items-center gap-2 border-b pb-3">
<FileText size="1.2em" class="text-secondary-500" /> <FileText size="1.2em" class="text-secondary-500" />
<h3 class="text-lg font-bold uppercase tracking-wider">Exhibitor Notes</h3> <h3
class="text-lg font-bold tracking-wider uppercase">
Exhibitor Notes
</h3>
</div> </div>
<div class="bg-surface-500/5 p-5 rounded-xl border border-surface-500/10 min-h-[120px]"> <div
class="bg-surface-500/5 border-surface-500/10 min-h-[120px] rounded-xl border p-5">
{#if is_edit_mode} {#if is_edit_mode}
<Element_ae_obj_field_editor <Element_ae_obj_field_editor
object_type="event_exhibit_tracking" object_type="event_exhibit_tracking"
@@ -252,14 +321,15 @@
field_type="tiptap" field_type="tiptap"
current_value={$lq__lead_obj.exhibitor_notes} current_value={$lq__lead_obj.exhibitor_notes}
object_reload={true} object_reload={true}
display_block={true} display_block={true} />
/>
{:else if $lq__lead_obj.exhibitor_notes} {:else if $lq__lead_obj.exhibitor_notes}
<div class="prose dark:prose-invert max-w-none leading-relaxed"> <div
class="prose dark:prose-invert max-w-none leading-relaxed">
{@html $lq__lead_obj.exhibitor_notes} {@html $lq__lead_obj.exhibitor_notes}
</div> </div>
{:else} {:else}
<div class="h-full flex items-center justify-center italic opacity-30 text-sm"> <div
class="flex h-full items-center justify-center text-sm italic opacity-30">
No notes have been added for this lead yet. No notes have been added for this lead yet.
</div> </div>
{/if} {/if}
@@ -270,69 +340,105 @@
<!-- Right: Metadata & Stats --> <!-- Right: Metadata & Stats -->
<div class="space-y-6"> <div class="space-y-6">
<!-- exhibit association --> <!-- exhibit association -->
<div class="card p-5 space-y-4 shadow-md bg-surface-100-900 border border-surface-500/10"> <div
<div class="flex items-center gap-2 text-primary-500"> class="card bg-surface-100-900 border-surface-500/10 space-y-4 border p-5 shadow-md">
<div class="text-primary-500 flex items-center gap-2">
<Store size="1.2em" /> <Store size="1.2em" />
<h3 class="font-bold uppercase text-xs tracking-widest">Exhibit Context</h3> <h3
class="text-xs font-bold tracking-widest uppercase">
Exhibit Context
</h3>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
{#if is_edit_mode} {#if is_edit_mode}
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<span class="text-sm opacity-60">Exhibit Name</span> <span class="text-sm opacity-60"
<span class="font-bold">{$lq__lead_obj.event_exhibit_name || '...'}</span> >Exhibit Name</span>
<span class="font-bold"
>{$lq__lead_obj.event_exhibit_name ||
'...'}</span>
</div> </div>
{/if} {/if}
<div class="flex justify-between items-center"> <div class="flex items-center justify-between">
<span class="text-sm opacity-60">Captured By</span> <span class="text-sm opacity-60"
<span class="font-mono text-[10px]">{$lq__lead_obj.external_person_id || 'Unknown'}</span> >Captured By</span>
<span class="font-mono text-[10px]"
>{$lq__lead_obj.external_person_id ||
'Unknown'}</span>
</div> </div>
{#if is_edit_mode} {#if is_edit_mode}
<div class="flex justify-between items-center pt-2 border-t border-surface-500/10"> <div
<span class="text-xs opacity-60 font-bold">Priority Lead</span> class="border-surface-500/10 flex items-center justify-between border-t pt-2">
<span class="text-xs font-bold opacity-60"
>Priority Lead</span>
<Element_ae_obj_field_editor <Element_ae_obj_field_editor
object_type="event_exhibit_tracking" object_type="event_exhibit_tracking"
object_id={exhibit_tracking_id ?? ''} object_id={exhibit_tracking_id ?? ''}
field_name="priority" field_name="priority"
field_type="checkbox" field_type="checkbox"
current_value={$lq__lead_obj.priority} current_value={$lq__lead_obj.priority}
object_reload={true} object_reload={true} />
/>
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
<!-- System Info --> <!-- System Info -->
<div class="card p-5 space-y-4 shadow-inner bg-surface-500/5 text-[10px] font-mono opacity-60"> <div
<div class="font-black uppercase tracking-[0.2em] border-b border-surface-500/10 pb-2 mb-2">System Audit</div> class="card bg-surface-500/5 space-y-4 p-5 font-mono text-[10px] opacity-60 shadow-inner">
<div
class="border-surface-500/10 mb-2 border-b pb-2 font-black tracking-[0.2em] uppercase">
System Audit
</div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div><span class="opacity-50">LEAD ID:</span> {$lq__lead_obj.event_exhibit_tracking_id}</div> <div>
<div><span class="opacity-50">BADGE ID:</span> {$lq__lead_obj.event_badge_id}</div> <span class="opacity-50">LEAD ID:</span>
<div><span class="opacity-50">PERSON ID:</span> {$lq__lead_obj.event_person_id}</div> {$lq__lead_obj.event_exhibit_tracking_id}
<div><span class="opacity-50">MODIFIED:</span> {format_date($lq__lead_obj.updated_on)}</div> </div>
<div>
<span class="opacity-50">BADGE ID:</span>
{$lq__lead_obj.event_badge_id}
</div>
<div>
<span class="opacity-50">PERSON ID:</span>
{$lq__lead_obj.event_person_id}
</div>
<div>
<span class="opacity-50">MODIFIED:</span>
{format_date($lq__lead_obj.updated_on)}
</div>
</div> </div>
</div> </div>
<!-- Restore Lead card — only shown when lead has been removed (enable=false/0). <!-- Restore Lead card — only shown when lead has been removed (enable=false/0).
Removing sets enable=false rather than deleting so notes/responses are preserved. --> Removing sets enable=false rather than deleting so notes/responses are preserved. -->
{#if !$lq__lead_obj.enable} {#if !$lq__lead_obj.enable}
<div class="card p-4 space-y-3 preset-tonal-error border border-error-500/50 shadow-sm"> <div
class="card preset-tonal-error border-error-500/50 space-y-3 border p-4 shadow-sm">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ShieldCheck size="1.4em" class="text-error-500 shrink-0" /> <ShieldCheck
size="1.4em"
class="text-error-500 shrink-0" />
<div> <div>
<div class="font-bold text-sm">Lead Removed</div> <div class="text-sm font-bold">
<div class="text-[10px] opacity-60 uppercase font-black">Not visible in leads list</div> Lead Removed
</div>
<div
class="text-[10px] font-black uppercase opacity-60">
Not visible in leads list
</div>
</div> </div>
</div> </div>
<button <button
type="button" type="button"
class="btn btn-sm w-full preset-filled-success font-bold" class="btn btn-sm preset-filled-success w-full font-bold"
disabled={remove_status === 'restoring'} disabled={remove_status === 'restoring'}
onclick={restore_lead} onclick={restore_lead}>
>
{#if remove_status === 'restoring'} {#if remove_status === 'restoring'}
<LoaderCircle size="1em" class="animate-spin" /> <LoaderCircle
size="1em"
class="animate-spin" />
Restoring... Restoring...
{:else} {:else}
<RotateCcw size="1em" /> <RotateCcw size="1em" />
@@ -342,15 +448,14 @@
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
</section> </section>
<style lang="postcss"> <style lang="postcss">
.lead-detail-view { .lead-detail-view {
/* Ensure we match the theme's background */ /* Ensure we match the theme's background */
@apply bg-transparent; @apply bg-transparent;
} }
</style> </style>

View File

@@ -1,153 +1,164 @@
<script lang="ts"> <script lang="ts">
/** /**
* 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): * Question schema (event_exhibit.leads_custom_questions_json):
* [{ code, question, type, option_li }] * [{ code, question, type, option_li }]
* - code: machine key — used as the property name in responses_json * - code: machine key — used as the property name in responses_json
* - question: human-readable label shown to the exhibitor during capture/review * - question: human-readable label shown to the exhibitor during capture/review
* - type: 'text' | 'textarea' | 'toggle' | 'option' * - type: 'text' | 'textarea' | 'toggle' | 'option'
* - option_li: array of choices; first element is always '' (blank default) * - option_li: array of choices; first element is always '' (blank default)
* *
* Response storage (event_exhibit_tracking.responses_json): * Response storage (event_exhibit_tracking.responses_json):
* { [code]: { response: <value> } } * { [code]: { response: <value> } }
* e.g. { "giveaway": { "response": "yes" }, "interest_level": { "response": "Hot" } } * e.g. { "giveaway": { "response": "yes" }, "interest_level": { "response": "Hot" } }
* *
* Backward compat: older questions may use `label` instead of `code`/`question`, * Backward compat: older questions may use `label` instead of `code`/`question`,
* and older responses may store scalars directly (not wrapped in {response: ...}). * and older responses may store scalars directly (not wrapped in {response: ...}).
* Both are handled transparently. * 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';
import { events_func } from '$lib/ae_events/ae_events_functions'; import { events_func } from '$lib/ae_events/ae_events_functions';
import { CircleCheck, LoaderCircle, Save } from '@lucide/svelte'; import { CircleCheck, LoaderCircle, Save } from '@lucide/svelte';
interface Props { interface Props {
exhibit_tracking_id: string; exhibit_tracking_id: string;
exhibit_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, exhibit_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([]);
// flat_responses: keyed by question code, stores scalar values for form binding. // 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. // We unwrap the nested {response: value} format on load and re-wrap on save.
let flat_responses: Record<string, any> = $state({}); let flat_responses: Record<string, any> = $state({});
let status = $state('idle'); // idle, saving, success let status = $state('idle'); // idle, saving, success
$effect(() => { $effect(() => {
try { try {
const defs = typeof custom_questions_json === 'string' const defs =
typeof custom_questions_json === 'string'
? JSON.parse(custom_questions_json || '[]') ? JSON.parse(custom_questions_json || '[]')
: (custom_questions_json || []); : custom_questions_json || [];
const raw = typeof current_responses_json === 'string' const raw =
typeof current_responses_json === 'string'
? JSON.parse(current_responses_json || '{}') ? JSON.parse(current_responses_json || '{}')
: (current_responses_json || {}); : current_responses_json || {};
untrack(() => { untrack(() => {
question_defs = defs; question_defs = defs;
// Flatten: unwrap {response: value} → scalar for form binding // Flatten: unwrap {response: value} → scalar for form binding
const flat: Record<string, any> = {}; const flat: Record<string, any> = {};
for (const [key, val] of Object.entries(raw)) { for (const [key, val] of Object.entries(raw)) {
if (val !== null && typeof val === 'object' && 'response' in (val as object)) { if (
flat[key] = (val as any).response ?? ''; val !== null &&
} else { typeof val === 'object' &&
flat[key] = val ?? ''; // legacy scalar '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({ flat_responses = flat;
api_cfg: $ae_api, });
exhibit_id: exhibit_id, } catch (e) {
exhibit_tracking_id: exhibit_tracking_id, console.error('Failed to parse questions/responses', e);
data: {
responses_json: JSON.stringify(nested)
}
});
status = 'success';
setTimeout(() => status = 'idle', 2000);
} catch (e) {
console.error('Failed to update responses', e);
status = 'idle';
}
} }
});
// 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(nested)
}
});
status = 'success';
setTimeout(() => (status = 'idle'), 2000);
} catch (e) {
console.error('Failed to update responses', e);
status = 'idle';
}
}
</script> </script>
<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 gap-6 sm:grid-cols-2">
{#each question_defs as q (q_key(q))} {#each question_defs as q (q_key(q))}
{@const key = q_key(q)} {@const key = q_key(q)}
{@const display = q.question || q.label || key} {@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">{display}</span> <span
class="ml-1 text-[10px] font-black tracking-widest uppercase opacity-40"
>{display}</span>
{#if q.type === 'textarea'} {#if q.type === 'textarea'}
<textarea <textarea
bind:value={flat_responses[key]} bind:value={flat_responses[key]}
class="textarea rounded-lg p-3 text-sm" class="textarea rounded-lg p-3 text-sm"
rows="3" rows="3"
placeholder="Type response..." placeholder="Type response..."></textarea>
></textarea>
{:else if q.type === 'toggle'} {:else if q.type === 'toggle'}
<div class="flex items-center gap-4 p-3 preset-tonal-surface rounded-lg"> <div
class="preset-tonal-surface flex items-center gap-4 rounded-lg p-3">
<input <input
type="checkbox" type="checkbox"
bind:checked={flat_responses[key]} bind:checked={flat_responses[key]}
class="checkbox" class="checkbox" />
/> <span class="text-sm font-bold"
<span class="text-sm font-bold">{flat_responses[key] ? 'Yes' : 'No'}</span> >{flat_responses[key] ? 'Yes' : 'No'}</span>
</div> </div>
{:else if q.type === 'option' || q.type === 'select'} {:else if q.type === 'option' || q.type === 'select'}
<!-- type 'option' is the current schema; 'select' is legacy compat --> <!-- type 'option' is the current schema; 'select' is legacy compat -->
<select <select
bind:value={flat_responses[key]} bind:value={flat_responses[key]}
class="select rounded-lg p-3 text-sm" class="select rounded-lg p-3 text-sm">
>
{#if Array.isArray(q.option_li)} {#if Array.isArray(q.option_li)}
{#each q.option_li as opt (opt)} {#each q.option_li as opt (opt)}
<option value={opt}>{opt || '-- Select --'}</option> <option value={opt}
>{opt || '-- Select --'}</option>
{/each} {/each}
{:else} {:else}
<!-- Legacy: options was a comma-separated string --> <!-- Legacy: options was a comma-separated string -->
<option value="">-- Select Option --</option> <option value="">-- Select Option --</option>
{#each (q.options || '').split(',').map((o: string) => o.trim()) as opt (opt)} {#each (q.options || '')
.split(',')
.map((o: string) => o.trim()) as opt (opt)}
<option value={opt}>{opt}</option> <option value={opt}>{opt}</option>
{/each} {/each}
{/if} {/if}
</select> </select>
{:else} {:else}
<input <input
type="text" type="text"
bind:value={flat_responses[key]} bind:value={flat_responses[key]}
class="input rounded-lg p-3 text-sm" class="input rounded-lg p-3 text-sm"
placeholder="Type response..." placeholder="Type response..." />
/>
{/if} {/if}
</label> </label>
</div> </div>
@@ -155,16 +166,17 @@
</div> </div>
{#if question_defs.length === 0} {#if question_defs.length === 0}
<p class="text-center opacity-30 italic py-4">No custom questions configured for this exhibit.</p> <p class="py-4 text-center italic opacity-30">
No custom questions configured for this exhibit.
</p>
{/if} {/if}
<button <button
class="btn preset-filled-primary w-full font-bold shadow-lg" class="btn preset-filled-primary w-full font-bold shadow-lg"
disabled={status === 'saving'} disabled={status === 'saving'}
onclick={handle_save} onclick={handle_save}>
>
{#if status === 'saving'} {#if status === 'saving'}
<LoaderCircle size="1.2em" class="animate-spin mr-2" /> Saving... <LoaderCircle size="1.2em" class="mr-2 animate-spin" /> Saving...
{:else if status === 'success'} {:else if status === 'success'}
<CircleCheck size="1.2em" class="mr-2" /> Saved! <CircleCheck size="1.2em" class="mr-2" /> Saved!
{:else} {:else}