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
@@ -32,7 +33,7 @@ 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 |
@@ -43,7 +44,7 @@ All data is cached in IndexedDB (Dexie.js) for offline use, with background API
### 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 |
@@ -60,7 +61,7 @@ All data is cached in IndexedDB (Dexie.js) for offline use, with background API
### 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

@@ -7,5 +7,4 @@
// Basic layout for the leads module // Basic layout for the leads module
</script> </script>
{@render children?.()} {@render children?.()}

View File

@@ -91,7 +91,8 @@
// --- Search Constraint: Min 3 characters for non-trusted users --- // --- Search Constraint: Min 3 characters for non-trusted users ---
if (!$ae_loc.trusted_access && qry_str.length < 3) { if (!$ae_loc.trusted_access && qry_str.length < 3) {
if (log_lvl) console.log('🛑 [Trace] Search string too short for public user.'); if (log_lvl)
console.log('🛑 [Trace] Search string too short for public user.');
untrack(() => { untrack(() => {
exhibit_id_li = []; exhibit_id_li = [];
$events_sess.leads.submit_status__search = 'idle'; $events_sess.leads.submit_status__search = 'idle';
@@ -99,7 +100,10 @@
return; return;
} }
if (log_lvl) console.log(`🔎 [Trace] Exhibit Search #${current_search_id}: START (remote=${remote_first}, event=${event_id}, str=${params.str})`); if (log_lvl)
console.log(
`🔎 [Trace] Exhibit Search #${current_search_id}: START (remote=${remote_first}, event=${event_id}, str=${params.str})`
);
untrack(() => { untrack(() => {
$events_sess.leads.submit_status__search = 'searching'; $events_sess.leads.submit_status__search = 'searching';
@@ -113,15 +117,13 @@
.equals(event_id) .equals(event_id)
.filter((exhibit) => { .filter((exhibit) => {
// Priority Filter for Public // Priority Filter for Public
if (!$ae_loc.manager_access && !exhibit.priority) return false; if (!$ae_loc.manager_access && !exhibit.priority)
return false;
if (qry_str) { if (qry_str) {
const name = (exhibit.name ?? '').toLowerCase(); const name = (exhibit.name ?? '').toLowerCase();
const code = (exhibit.code ?? '').toLowerCase(); const code = (exhibit.code ?? '').toLowerCase();
if ( if (!name.includes(qry_str) && !code.includes(qry_str))
!name.includes(qry_str) &&
!code.includes(qry_str)
)
return false; return false;
} else if (!$ae_loc.trusted_access) { } else if (!$ae_loc.trusted_access) {
// Don't show default results to public if no search string // Don't show default results to public if no search string
@@ -156,7 +158,10 @@
.filter(Boolean); .filter(Boolean);
if (current_search_id === last_search_id) { if (current_search_id === last_search_id) {
if (log_lvl) console.log(`✅ [Trace] Exhibit Search #${current_search_id}: Local path found ${local_ids.length} items.`); if (log_lvl)
console.log(
`✅ [Trace] Exhibit Search #${current_search_id}: Local path found ${local_ids.length} items.`
);
untrack(() => { untrack(() => {
exhibit_id_li = local_ids; exhibit_id_li = local_ids;
}); });
@@ -203,7 +208,10 @@
.map((e: any) => String(e.id || e.event_exhibit_id)) .map((e: any) => String(e.id || e.event_exhibit_id))
.filter(Boolean); .filter(Boolean);
if (log_lvl) console.log(`📦 [Trace] Exhibit Search #${current_search_id}: API revalidation found ${api_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 = api_ids;
@@ -222,33 +230,29 @@
</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

@@ -7,7 +7,12 @@
let { event_id, log_lvl = 0 }: Props = $props(); let { event_id, log_lvl = 0 }: Props = $props();
// *** Import other supporting libraries // *** Import other supporting libraries
import { Library, LoaderCircle, RemoveFormatting, Search } from '@lucide/svelte'; import {
Library,
LoaderCircle,
RemoveFormatting,
Search
} from '@lucide/svelte';
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';
@@ -27,18 +32,15 @@
</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

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

@@ -11,7 +11,15 @@
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 {
CreditCard,
Download,
LayoutGrid,
List as ListIcon,
LoaderCircle,
Plus,
Settings
} from '@lucide/svelte';
import Comp_exhibit_tracking_search from './ae_comp__exhibit_tracking_search.svelte'; import Comp_exhibit_tracking_search from './ae_comp__exhibit_tracking_search.svelte';
import Comp_exhibit_tracking_obj_li from './ae_comp__exhibit_tracking_obj_li.svelte'; import Comp_exhibit_tracking_obj_li from './ae_comp__exhibit_tracking_obj_li.svelte';
import Tab_add from './ae_tab__add.svelte'; import Tab_add from './ae_tab__add.svelte';
@@ -23,9 +31,7 @@
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; $events_loc.leads.tracking__qry__remote_first = false;
if (typeof $events_loc.leads.tracking__qry__search_text === 'undefined') if (typeof $events_loc.leads.tracking__qry__search_text === 'undefined')
$events_loc.leads.tracking__qry__search_text = ''; $events_loc.leads.tracking__qry__search_text = '';
@@ -78,7 +84,7 @@
const licensee_filter = search_params.licensee_email; const licensee_filter = search_params.licensee_email;
const show_hidden = search_params.show_hidden; const show_hidden = search_params.show_hidden;
return raw_lead_li.filter(lead => { return raw_lead_li.filter((lead) => {
// Never show disabled (removed) leads — enable=0/false means the exhibitor deleted them // Never show disabled (removed) leads — enable=0/false means the exhibitor deleted them
if (lead.enable === 0 || lead.enable === false) return false; if (lead.enable === 0 || lead.enable === false) return false;
// Exclude hidden leads unless show_hidden is toggled on // Exclude hidden leads unless show_hidden is toggled on
@@ -114,7 +120,7 @@
return []; return [];
}); });
const subscription = observable.subscribe(res => { const subscription = observable.subscribe((res) => {
raw_lead_li = res; raw_lead_li = res;
}); });
@@ -134,7 +140,9 @@
// Resolve "My Leads" to actual email // Resolve "My Leads" to actual email
if (licensee_email === 'my') { if (licensee_email === 'my') {
licensee_email = $events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? '']?.key || 'all'; licensee_email =
$events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? '']
?.key || 'all';
} }
return { return {
@@ -157,7 +165,8 @@
untrack(() => { untrack(() => {
handle_search_refresh(params); handle_search_refresh(params);
// Reset countdown on manual search // Reset countdown on manual search
$events_sess.leads.next_refresh_countdown = $events_loc.leads.refresh_interval_sec || 25; $events_sess.leads.next_refresh_countdown =
$events_loc.leads.refresh_interval_sec || 25;
}); });
}, 300); }, 300);
return () => { return () => {
@@ -176,7 +185,8 @@
} else { } else {
// Trigger refresh // Trigger refresh
$events_loc.leads.tracking__search_version++; $events_loc.leads.tracking__search_version++;
$events_sess.leads.next_refresh_countdown = $events_loc.leads.refresh_interval_sec || 25; $events_sess.leads.next_refresh_countdown =
$events_loc.leads.refresh_interval_sec || 25;
} }
}); });
}, 1000); }, 1000);
@@ -195,7 +205,10 @@
if (!exhibit_id) return; 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})`); if (log_lvl)
console.log(
`🔎 [Trace] Lead Search #${current_search_id}: START (remote=${remote_first}, exhibit=${exhibit_id}, str=${params.str})`
);
untrack(() => { untrack(() => {
$events_sess.leads.submit_status__search = 'searching'; $events_sess.leads.submit_status__search = 'searching';
@@ -220,7 +233,11 @@
// 2. Licensee Email Filter // 2. Licensee Email Filter
if (target_licensee_email !== 'all') { if (target_licensee_email !== 'all') {
if (tracking.external_person_id !== target_licensee_email) return false; if (
tracking.external_person_id !==
target_licensee_email
)
return false;
} }
if (qry_str) { if (qry_str) {
@@ -230,7 +247,9 @@
const email = ( const email = (
tracking.event_badge_email ?? '' tracking.event_badge_email ?? ''
).toLowerCase(); ).toLowerCase();
const notes = ae_util.strip_html(tracking.exhibitor_notes ?? '').toLowerCase(); const notes = ae_util
.strip_html(tracking.exhibitor_notes ?? '')
.toLowerCase();
// Guard: Prevent "undefined" from being searched // Guard: Prevent "undefined" from being searched
if (tracking.exhibitor_notes === 'undefined') { if (tracking.exhibitor_notes === 'undefined') {
tracking.exhibitor_notes = ''; tracking.exhibitor_notes = '';
@@ -254,13 +273,13 @@
local_results.sort((a, b) => { local_results.sort((a, b) => {
switch (params.sort) { switch (params.sort) {
case 'name_asc': case 'name_asc':
return ( return (a.event_badge_full_name ?? '').localeCompare(
a.event_badge_full_name ?? ''
).localeCompare(b.event_badge_full_name ?? '');
case 'name_desc':
return (
b.event_badge_full_name ?? '' b.event_badge_full_name ?? ''
).localeCompare(a.event_badge_full_name ?? ''); );
case 'name_desc':
return (b.event_badge_full_name ?? '').localeCompare(
a.event_badge_full_name ?? ''
);
case 'created_asc': case 'created_asc':
return ( return (
new Date(a.created_on || 0).getTime() - new Date(a.created_on || 0).getTime() -
@@ -280,13 +299,14 @@
}); });
const local_ids = local_results const local_ids = local_results
.map((e) => .map((e) => String(e.id || e.event_exhibit_tracking_id))
String(e.id || e.event_exhibit_tracking_id)
)
.filter(Boolean); .filter(Boolean);
if (current_search_id === last_search_id) { 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.`); if (log_lvl)
console.log(
`✅ [Trace] Lead Search #${current_search_id}: Local path found ${local_ids.length} items.`
);
untrack(() => { untrack(() => {
tracking_id_li = local_ids; tracking_id_li = local_ids;
}); });
@@ -318,7 +338,10 @@
const q_event_id: string = page.params.event_id ?? ''; const q_event_id: string = page.params.event_id ?? '';
const q_exhibit_id: string = exhibit_id ?? ''; const q_exhibit_id: string = exhibit_id ?? '';
const q_licensee_email: string | null = (params.licensee_email !== 'all') ? (params.licensee_email ?? '') : null; const q_licensee_email: string | null =
params.licensee_email !== 'all'
? (params.licensee_email ?? '')
: null;
const results = await events_func.search__exhibit_tracking({ const results = await events_func.search__exhibit_tracking({
api_cfg: $ae_api, api_cfg: $ae_api,
@@ -333,12 +356,13 @@
if (current_search_id === last_search_id) { if (current_search_id === last_search_id) {
const api_ids = results const api_ids = results
.map((e: any) => .map((e: any) => String(e.id || e.event_exhibit_tracking_id))
String(e.id || e.event_exhibit_tracking_id)
)
.filter(Boolean); .filter(Boolean);
if (log_lvl) console.log(`📦 [Trace] Lead Search #${current_search_id}: API revalidation found ${api_ids.length} items.`); 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 = api_ids;
@@ -384,19 +408,20 @@
set_active_tab('manage'); 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

@@ -6,7 +6,14 @@
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 {
List,
LoaderCircle,
MessageSquare,
Plus,
Save,
Trash2
} from '@lucide/svelte';
interface Props { interface Props {
exhibit_id: string; exhibit_id: string;
event_id: string; event_id: string;
@@ -38,7 +45,12 @@
// 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 = '[]';
}
});
} }
}); });
@@ -87,52 +99,88 @@
function set_options_str(q: any, val: string) { function set_options_str(q: any, val: string) {
// Always prepend empty string so the select has a blank default option // Always prepend empty string so the select has a blank default option
q.option_li = ['', ...val.split(',').map((s: string) => s.trim()).filter(Boolean)]; 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

@@ -6,7 +6,16 @@
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 {
Key,
LoaderCircle,
Mail,
Plus,
Save,
Trash2,
User,
Users
} from '@lucide/svelte';
interface Props { interface Props {
exhibit_id: string; exhibit_id: string;
event_id: string; event_id: string;
@@ -14,7 +23,12 @@
license_max?: number; 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([]);
@@ -25,7 +39,7 @@
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; return;
} }
@@ -81,7 +95,9 @@
} }
function remove_license(index: number) { function remove_license(index: number) {
if (confirm('Remove this license? The user will lose access immediately.')) { if (
confirm('Remove this license? The user will lose access immediately.')
) {
local_license_li.splice(index, 1); local_license_li.splice(index, 1);
} }
} }
@@ -89,20 +105,22 @@
<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

@@ -5,7 +5,7 @@
*/ */
</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

@@ -8,7 +8,16 @@
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 {
ArrowRight,
CircleAlert,
CircleCheck,
Key,
LoaderCircle,
Lock,
Mail,
User
} from '@lucide/svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
const exhibit_id = $derived(page.params.exhibit_id ?? ''); const exhibit_id = $derived(page.params.exhibit_id ?? '');
@@ -43,7 +52,7 @@
error_msg = ''; error_msg = '';
// Delay for better UX // Delay for better UX
await new Promise(r => setTimeout(r, 800)); await new Promise((r) => setTimeout(r, 800));
if (signin_mode === 'passcode') { if (signin_mode === 'passcode') {
// 1. Shared Passcode logic // 1. Shared Passcode logic
@@ -52,7 +61,8 @@
complete_signin($lq__exhibit_obj.staff_passcode, 'shared'); complete_signin($lq__exhibit_obj.staff_passcode, 'shared');
} else { } else {
status = 'error'; status = 'error';
error_msg = 'Invalid shared passcode. Please check with your booth manager.'; error_msg =
'Invalid shared passcode. Please check with your booth manager.';
} }
} else { } else {
// 2. Licensed User logic // 2. Licensed User logic
@@ -61,9 +71,17 @@
const raw_json = $lq__exhibit_obj?.license_li_json; const raw_json = $lq__exhibit_obj?.license_li_json;
// Parse if string, otherwise use empty array // Parse if string, otherwise use empty array
const licenses = typeof raw_json === 'string' ? JSON.parse(raw_json || '[]') : (Array.isArray(raw_json) ? raw_json : []); 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()); const found = licenses.find(
(l: any) =>
l.email?.toLowerCase() === email.toLowerCase().trim()
);
if (found && found.passcode === user_passcode) { if (found && found.passcode === user_passcode) {
// SUCCESS // SUCCESS
@@ -83,7 +101,8 @@
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,
@@ -101,116 +120,134 @@
} }
</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);
}
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
} }
.animate-shake { .animate-shake {
animation: shake 0.2s ease-in-out 0s 2; animation: shake 0.2s ease-in-out 0s 2;

View File

@@ -6,7 +6,15 @@
let { lq__event_exhibit_tracking_obj_li, log_lvl = 0 }: Props = $props(); let { lq__event_exhibit_tracking_obj_li, log_lvl = 0 }: Props = $props();
import { ChevronRight, Clock, FileText, LoaderCircle, Mail, MapPin, User } from '@lucide/svelte'; import {
ChevronRight,
Clock,
FileText,
LoaderCircle,
Mail,
MapPin,
User
} from '@lucide/svelte';
import { ae_util } from '$lib/ae_utils/ae_utils'; import { ae_util } from '$lib/ae_utils/ae_utils';
import { page } from '$app/state'; import { page } from '$app/state';
@@ -14,8 +22,12 @@
function format_date_full(date_str: string) { function format_date_full(date_str: string) {
if (!date_str) return ''; if (!date_str) return '';
return new Date(date_str).toLocaleString(undefined, { return new Date(date_str).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit' month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}); });
} }
@@ -43,12 +55,18 @@
if (days < 7) { if (days < 7) {
const rem_hr = Math.round(hr - days * 24); const rem_hr = Math.round(hr - days * 24);
const day_label = `~${days} day${days > 1 ? 's' : ''}`; const day_label = `~${days} day${days > 1 ? 's' : ''}`;
return rem_hr > 1 ? `${day_label} ${rem_hr} hrs ago` : `${day_label} ago`; return rem_hr > 1
? `${day_label} ${rem_hr} hrs ago`
: `${day_label} ago`;
} }
const weeks = Math.round(days / 7); const weeks = Math.round(days / 7);
if (days < 28) { return `~${weeks} week${weeks > 1 ? 's' : ''} ago`; } if (days < 28) {
return `~${weeks} week${weeks > 1 ? 's' : ''} ago`;
}
const months = Math.round(days / 30); const months = Math.round(days / 30);
if (days < 365) { return `~${months} month${months > 1 ? 's' : ''} ago`; } if (days < 365) {
return `~${months} month${months > 1 ? 's' : ''} ago`;
}
const years = Math.round(days / 365); const years = Math.round(days / 365);
return `~${years} year${years > 1 ? 's' : ''} ago`; return `~${years} year${years > 1 ? 's' : ''} ago`;
} }
@@ -57,18 +75,21 @@
<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

@@ -7,7 +7,14 @@
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 {
Eye,
EyeOff,
Library,
LoaderCircle,
RemoveFormatting,
Search
} from '@lucide/svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
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';
@@ -24,7 +31,10 @@
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')
.equals(exhibit_id)
.first();
} }
return res; return res;
}); });
@@ -57,7 +67,10 @@
if (!exhibit_obj) return; if (!exhibit_obj) return;
untrack(() => { untrack(() => {
if ($events_loc.leads.tracking__qry__licensee_email === 'all' && !$ae_loc.administrator_access) { if (
$events_loc.leads.tracking__qry__licensee_email === 'all' &&
!$ae_loc.administrator_access
) {
$events_loc.leads.tracking__qry__licensee_email = 'my'; $events_loc.leads.tracking__qry__licensee_email = 'my';
} }
}); });
@@ -79,18 +92,15 @@
</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

@@ -30,9 +30,15 @@
// Map badge_id -> tracking_id // Map badge_id -> tracking_id
const map = new Map(); const map = new Map();
leads.forEach(l => { leads.forEach((l) => {
const b_id = l.event_badge_id_random || l.event_badge_id?.toString(); const b_id =
if (b_id) map.set(b_id, l.event_exhibit_tracking_id_random || l.event_exhibit_tracking_id?.toString()); 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; return map;
}) })
@@ -71,7 +77,10 @@
// NO LONGER USE "_random" // NO LONGER USE "_random"
const badge_id = badge.event_badge_id_random || badge.event_badge_id; const badge_id = badge.event_badge_id_random || badge.event_badge_id;
if (!badge_id) { if (!badge_id) {
console.warn('[add_as_lead] badge missing event_badge_id_random and event_badge_id', badge); console.warn(
'[add_as_lead] badge missing event_badge_id_random and event_badge_id',
badge
);
return; return;
} }
@@ -79,7 +88,10 @@
// Defensive guard — the UI already hides the Add button for blocked badges, // Defensive guard — the UI already hides the Add button for blocked badges,
// but this prevents any direct/programmatic calls from bypassing the check. // but this prevents any direct/programmatic calls from bypassing the check.
if (badge.allow_tracking !== true) { if (badge.allow_tracking !== true) {
console.warn('[add_as_lead] blocked — allow_tracking is not true for badge', badge_id); console.warn(
'[add_as_lead] blocked — allow_tracking is not true for badge',
badge_id
);
return; return;
} }
@@ -87,8 +99,11 @@
add_error_id = ''; add_error_id = '';
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id]; const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const user_email = kv?.type === 'licensed' && kv.key ? kv.key const user_email =
: kv?.type === 'shared' ? 'shared_passcode' kv?.type === 'licensed' && kv.key
? kv.key
: kv?.type === 'shared'
? 'shared_passcode'
: $ae_loc.access_type || 'anonymous'; : $ae_loc.access_type || 'anonymous';
try { try {
@@ -103,12 +118,17 @@
if (result) { if (result) {
// Surface a View Details link next to this result row // Surface a View Details link next to this result row
last_added_badge_id = badge_id; last_added_badge_id = badge_id;
last_added_tracking_id = result.event_exhibit_tracking_id_random || String(result.event_exhibit_tracking_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); if (on_lead_added) on_lead_added(badge);
} else { } else {
// API returned null/false — surface a visible error on this row // API returned null/false — surface a visible error on this row
add_error_id = badge_id; add_error_id = badge_id;
console.warn('[add_as_lead] API returned null for badge_id', badge_id); console.warn(
'[add_as_lead] API returned null for badge_id',
badge_id
);
} }
} catch (e) { } catch (e) {
add_error_id = badge_id; add_error_id = badge_id;
@@ -119,28 +139,29 @@
} }
</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

@@ -16,7 +16,17 @@
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 {
Camera,
CircleAlert,
CircleCheck,
Eye,
LoaderCircle,
RefreshCw,
RotateCcw,
ShieldOff,
X
} from '@lucide/svelte';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import type { ae_EventBadge } from '$lib/types/ae_types'; import type { ae_EventBadge } from '$lib/types/ae_types';
@@ -37,10 +47,14 @@
.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,
{ tracking_id: string; enabled: boolean }
>();
leads.forEach((l) => {
const b_id = l.event_badge_id?.toString(); const b_id = l.event_badge_id?.toString();
if (b_id) map.set(b_id, { 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
@@ -57,7 +71,9 @@
let new_tracking_id = $state(''); // ID of the lead just created — used for "View Details" link let new_tracking_id = $state(''); // ID of the lead just created — used for "View Details" link
let error_msg = $state(''); let error_msg = $state('');
async function handle_qr_scan_result(event: { detail: { result: string; entry_method: string } }) { async function handle_qr_scan_result(event: {
detail: { result: string; entry_method: string };
}) {
const qr_result = event.detail.result; const qr_result = event.detail.result;
const obj = ae_util.process_data_string(qr_result); const obj = ae_util.process_data_string(qr_result);
@@ -85,7 +101,10 @@
// Gate: attendee must have opted in to lead tracking. // Gate: attendee must have opted in to lead tracking.
// allow_tracking must be explicitly true — default on badges is false (opt-in model). // allow_tracking must be explicitly true — default on badges is false (opt-in model).
// Only applies to the 'found' state; already-captured badges are left as-is. // Only applies to the 'found' state; already-captured badges are left as-is.
if (scanning_status === 'found' && found_badge?.allow_tracking !== true) { if (
scanning_status === 'found' &&
found_badge?.allow_tracking !== true
) {
scanning_status = 'tracking_blocked'; scanning_status = 'tracking_blocked';
} }
@@ -104,7 +123,10 @@
async function confirm_add_lead(dest: 'scan_next' | 'view_lead' = 'scan_next') { async function confirm_add_lead(dest: 'scan_next' | 'view_lead' = 'scan_next') {
if (!found_badge || !found_badge.event_badge_id) { if (!found_badge || !found_badge.event_badge_id) {
console.warn('[leads] Guard failed — event_badge_id missing. found_badge:', found_badge); console.warn(
'[leads] Guard failed — event_badge_id missing. found_badge:',
found_badge
);
return; return;
} }
@@ -115,8 +137,11 @@
// shared passcode → 'shared_passcode' label (don't store the actual passcode) // shared passcode → 'shared_passcode' label (don't store the actual passcode)
// Aether user (no kv) → access_type string ('trusted', 'manager', 'super', etc.) // Aether user (no kv) → access_type string ('trusted', 'manager', 'super', etc.)
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id]; const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const user_email = kv?.type === 'licensed' && kv.key ? kv.key const user_email =
: kv?.type === 'shared' ? 'shared_passcode' kv?.type === 'licensed' && kv.key
? kv.key
: kv?.type === 'shared'
? 'shared_passcode'
: $ae_loc.access_type || 'anonymous'; : $ae_loc.access_type || 'anonymous';
try { try {
@@ -136,7 +161,9 @@
if (dest === 'view_lead' && new_tracking_id) { if (dest === 'view_lead' && new_tracking_id) {
// View Lead: navigate directly to lead detail to fill in notes/qualifiers // 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}`); goto(
`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`
);
} else { } else {
// Scan Next / auto mode: auto-reset after 2 seconds to scan the next person // Scan Next / auto mode: auto-reset after 2 seconds to scan the next person
setTimeout(reset_scanner, 2000); setTimeout(reset_scanner, 2000);
@@ -156,15 +183,19 @@
}); });
if (disabled_li.length > 0) { if (disabled_li.length > 0) {
// Found a disabled record — offer to re-activate instead of showing an error // Found a disabled record — offer to re-activate instead of showing an error
existing_tracking_id = String(disabled_li[0].event_exhibit_tracking_id || ''); existing_tracking_id = String(
disabled_li[0].event_exhibit_tracking_id || ''
);
scanning_status = 'reenable'; scanning_status = 'reenable';
} else { } else {
scanning_status = 'error'; scanning_status = 'error';
error_msg = 'Failed to add lead. Check your connection and try again.'; error_msg =
'Failed to add lead. Check your connection and try again.';
} }
} catch { } catch {
scanning_status = 'error'; scanning_status = 'error';
error_msg = 'Failed to add lead. Check your connection and try again.'; error_msg =
'Failed to add lead. Check your connection and try again.';
} }
} }
} catch { } catch {
@@ -173,7 +204,9 @@
} }
} }
async function confirm_reenable_lead(dest: 'scan_next' | 'view_lead' = 'scan_next') { async function confirm_reenable_lead(
dest: 'scan_next' | 'view_lead' = 'scan_next'
) {
// Re-activate a lead that was previously removed (enable=false). // Re-activate a lead that was previously removed (enable=false).
// existing_tracking_id is already set from the map or the API fallback search. // existing_tracking_id is already set from the map or the API fallback search.
if (!existing_tracking_id) return; if (!existing_tracking_id) return;
@@ -192,7 +225,9 @@
scanning_status = 'success'; scanning_status = 'success';
if (on_lead_added && found_badge) on_lead_added(found_badge); if (on_lead_added && found_badge) on_lead_added(found_badge);
if (dest === 'view_lead' && new_tracking_id) { if (dest === 'view_lead' && new_tracking_id) {
goto(`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`); goto(
`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`
);
} else { } else {
setTimeout(reset_scanner, 2000); setTimeout(reset_scanner, 2000);
} }
@@ -215,61 +250,70 @@
} }
</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>
<p class="text-center opacity-70 italic text-sm">Point camera at the badge QR code</p> </div>
<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>
@@ -407,7 +462,11 @@
animation: scanner-reset-countdown 2s linear forwards; animation: scanner-reset-countdown 2s linear forwards;
} }
@keyframes scanner-reset-countdown { @keyframes scanner-reset-countdown {
from { width: 100%; } from {
to { width: 0%; } width: 100%;
}
to {
width: 0%;
}
} }
</style> </style>

View File

@@ -23,8 +23,16 @@
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,
Layers,
LoaderCircle,
RefreshCw,
RotateCcw,
ScanLine,
ShieldOff,
UserPlus,
X
} from '@lucide/svelte'; } from '@lucide/svelte';
interface Props { interface Props {
@@ -36,10 +44,19 @@
// 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
@@ -54,11 +71,17 @@
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 map = new SvelteMap<
string,
{ tracking_id: string; enabled: boolean }
>();
leads.forEach((l) => {
const b_id = l.event_badge_id?.toString(); const b_id = l.event_badge_id?.toString();
if (b_id) map.set(b_id, { 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
}); });
@@ -71,7 +94,9 @@
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<
'idle' | 'starting' | 'live' | 'capturing' | 'error'
>('idle');
let camera_error = $state(''); let camera_error = $state('');
// Start camera when the video element mounts // Start camera when the video element mounts
@@ -88,24 +113,34 @@
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',
width: { ideal: 1920 },
height: { ideal: 1080 }
}
}); });
if (!video_el) { stop_camera(); return; } if (!video_el) {
stop_camera();
return;
}
video_el.srcObject = stream; video_el.srcObject = stream;
await video_el.play(); await video_el.play();
// BarcodeDetector API — not yet typed in lib.dom.d.ts for all targets // BarcodeDetector API — not yet typed in lib.dom.d.ts for all targets
detector = new (window as any).BarcodeDetector({ formats: ['qr_code'] }); detector = new (window as any).BarcodeDetector({
formats: ['qr_code']
});
camera_status = 'live'; camera_status = 'live';
} catch (e: any) { } catch (e: any) {
camera_status = 'error'; camera_status = 'error';
camera_error = e?.name === 'NotAllowedError' 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() { function stop_camera() {
stream?.getTracks().forEach(t => t.stop()); stream?.getTracks().forEach((t) => t.stop());
stream = null; stream = null;
detector = null; detector = null;
if (camera_status !== 'error') camera_status = 'idle'; if (camera_status !== 'error') camera_status = 'idle';
@@ -118,21 +153,26 @@
// --- Batch --- // --- Batch ---
let batch = $state<BatchItem[]>([]); let batch = $state<BatchItem[]>([]);
let ready_count = $derived(batch.filter(i => i.status === 'ready' && !i.dismissing).length); let ready_count = $derived(
batch.filter((i) => i.status === 'ready' && !i.dismissing).length
);
async function capture_batch() { async function capture_batch() {
if (!detector || !video_el || camera_status !== 'live') return; if (!detector || !video_el || camera_status !== 'live') return;
camera_status = 'capturing'; camera_status = 'capturing';
try { try {
const barcodes: Array<{ rawValue: string }> = await detector.detect(video_el); const barcodes: Array<{ rawValue: string }> =
const existing_ids = new Set(batch.map(i => i.id)); await detector.detect(video_el);
const existing_ids = new Set(batch.map((i) => i.id));
const new_objs = barcodes const new_objs = barcodes
.map(b => ae_util.process_data_string(b.rawValue)) .map((b) => ae_util.process_data_string(b.rawValue))
.filter((obj): obj is { type: string; id: string } => .filter(
!!(obj && obj.type === 'event_badge' && obj.id)) (obj): obj is { type: string; id: string } =>
.filter(obj => !existing_ids.has(obj.id)) !!(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 .slice(0, Math.max(0, 8 - batch.length)); // hard cap at 8 total
for (const obj of new_objs) { for (const obj of new_objs) {
@@ -183,8 +223,11 @@
item.status = 'adding'; item.status = 'adding';
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id]; const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
const user_email = kv?.type === 'licensed' && kv.key ? kv.key const user_email =
: kv?.type === 'shared' ? 'shared_passcode' kv?.type === 'licensed' && kv.key
? kv.key
: kv?.type === 'shared'
? 'shared_passcode'
: $ae_loc.access_type || 'anonymous'; : $ae_loc.access_type || 'anonymous';
try { try {
const result = await events_func.create_ae_obj__exhibit_tracking({ const result = await events_func.create_ae_obj__exhibit_tracking({
@@ -232,7 +275,7 @@
} }
async function add_all() { async function add_all() {
const to_add = batch.filter(i => i.status === 'ready' && !i.dismissing); const to_add = batch.filter((i) => i.status === 'ready' && !i.dismissing);
await Promise.all(to_add.map(add_lead)); await Promise.all(to_add.map(add_lead));
} }
@@ -240,59 +283,71 @@
item.dismissing = true; item.dismissing = true;
// Remove from array after CSS transition completes (300ms) // Remove from array after CSS transition completes (300ms)
setTimeout(() => { setTimeout(() => {
const idx = batch.findIndex(i => i.id === item.id); const idx = batch.findIndex((i) => i.id === item.id);
if (idx >= 0) batch.splice(idx, 1); if (idx >= 0) batch.splice(idx, 1);
}, 350); }, 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,15 +569,13 @@
{#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>
@@ -499,7 +583,9 @@
/* 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,
transform 0.3s ease;
} }
.batch-card.dismissing { .batch-card.dismissing {
opacity: 0; opacity: 0;

View File

@@ -41,7 +41,8 @@
}); });
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 = {};
$events_loc.leads.tab_scan_qualify[exhibit_id] = new_mode; $events_loc.leads.tab_scan_qualify[exhibit_id] = new_mode;
show_mode_opts = false; show_mode_opts = false;
} }
@@ -60,22 +61,37 @@
desc: string; desc: string;
icon: any; 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: 'rapid',
{ value: 'multi', label: 'Multi', desc: 'Batch scan up to 4 badges', icon: Layers }, 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]); 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

@@ -11,7 +11,7 @@
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

@@ -13,7 +13,23 @@
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 {
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 ?? ''); const exhibit_id = $derived(page.params.exhibit_id ?? '');
let lq__exhibit_obj = $derived( let lq__exhibit_obj = $derived(
@@ -40,35 +56,51 @@
} }
</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
class="btn btn-sm preset-outlined-error"
onclick={() => {
if (confirm('Clear all local cached data (IDB)?')) { if (confirm('Clear all local cached data (IDB)?')) {
db_events.delete().then(() => window.location.reload()); 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"
onclick={() => {
if (
confirm(
'Reset all local app settings and sign out?'
)
) {
localStorage.clear(); localStorage.clear();
window.location.reload(); window.location.reload();
} }
}}> }}>
<UserX size="1em" class="mr-2" /> Clear Local Settings (Hard Reset) <UserX size="1em" class="mr-2" /> Clear Local Settings (Hard
Reset)
</button> </button>
</div> </div>
</div> </div>
@@ -409,11 +566,12 @@
</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">

View File

@@ -8,7 +8,12 @@
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 {
CircleCheck,
LayoutGrid,
ShieldCheck,
UserCheck
} from '@lucide/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(
@@ -19,51 +24,62 @@
); );
</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

@@ -12,7 +12,24 @@
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 {
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); const exhibit_tracking_id = $derived(page.params.exhibit_tracking_id);
let lq__lead_obj = $derived( let lq__lead_obj = $derived(
@@ -34,7 +51,9 @@
// 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 ?? '';
@@ -83,16 +102,16 @@
<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,7 +448,6 @@
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -29,7 +29,12 @@
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.
@@ -39,19 +44,25 @@
$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 (
val !== null &&
typeof val === 'object' &&
'response' in (val as object)
) {
flat[key] = (val as any).response ?? ''; flat[key] = (val as any).response ?? '';
} else { } else {
flat[key] = val ?? ''; // legacy scalar flat[key] = val ?? ''; // legacy scalar
@@ -87,7 +98,7 @@
} }
}); });
status = 'success'; status = 'success';
setTimeout(() => status = 'idle', 2000); setTimeout(() => (status = 'idle'), 2000);
} catch (e) { } catch (e) {
console.error('Failed to update responses', e); console.error('Failed to update responses', e);
status = 'idle'; status = 'idle';
@@ -96,58 +107,58 @@
</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}