Prettier for Event Exhibitor Leads
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
**PWA only** — no Electron involvement. The Electron app is exclusively for the Launcher.
|
||||
|
||||
Spec docs:
|
||||
|
||||
- `documentation/PROJECT__AE_Events_Exhibitor_Leads_v3.md` — overview
|
||||
- `documentation/PROJECT__AE_Events_Exhibitor_Leads_v3_detail.md` — tab-level detail
|
||||
|
||||
@@ -31,36 +32,36 @@ All data is cached in IndexedDB (Dexie.js) for offline use, with background API
|
||||
|
||||
### Routes
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `leads/+page.svelte` | Exhibit search/landing — find your booth |
|
||||
| `leads/+page.ts` | Layout data load |
|
||||
| `leads/exhibit/[exhibit_id]/+page.svelte` | Main exhibitor view — orchestrates all tabs |
|
||||
| `leads/exhibit/[exhibit_id]/+layout.svelte` / `+layout.ts` | Exhibit layout / data load |
|
||||
| `leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/+page.svelte` | Lead detail view/edit |
|
||||
| `leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/+page.ts` | Lead data load |
|
||||
| File | Role |
|
||||
| -------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| `leads/+page.svelte` | Exhibit search/landing — find your booth |
|
||||
| `leads/+page.ts` | Layout data load |
|
||||
| `leads/exhibit/[exhibit_id]/+page.svelte` | Main exhibitor view — orchestrates all tabs |
|
||||
| `leads/exhibit/[exhibit_id]/+layout.svelte` / `+layout.ts` | Exhibit layout / data load |
|
||||
| `leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/+page.svelte` | Lead detail view/edit |
|
||||
| `leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/+page.ts` | Lead data load |
|
||||
|
||||
### Components (within `exhibit/[exhibit_id]/`)
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `ae_tab__start.svelte` | Tab 1 — welcome + sign-in |
|
||||
| `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_comp__exhibit_signin.svelte` | Sign-in: shared passcode + licensed user |
|
||||
| `ae_comp__lead_qr_scanner.svelte` | QR scanner (rapid vs. qualify mode) |
|
||||
| `ae_comp__lead_manual_search.svelte` | Manual badge search + add |
|
||||
| `ae_comp__exhibit_tracking_search.svelte` | Lead list search/filter bar |
|
||||
| `ae_comp__exhibit_tracking_obj_li.svelte` | Lead list item renderer |
|
||||
| `ae_comp__exhibit_license_list.svelte` | License slot manager (admin) |
|
||||
| `ae_comp__exhibit_custom_questions.svelte` | Custom question config editor (admin) |
|
||||
| `ae_comp__exhibit_payment.svelte` | **STUB** — Stripe placeholder, not functional |
|
||||
| `ae_comp__exhibit_search.svelte` | Exhibit search input on the landing page |
|
||||
| File | Role |
|
||||
| ------------------------------------------ | ----------------------------------------------- |
|
||||
| `ae_tab__start.svelte` | Tab 1 — welcome + sign-in |
|
||||
| `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_comp__exhibit_signin.svelte` | Sign-in: shared passcode + licensed user |
|
||||
| `ae_comp__lead_qr_scanner.svelte` | QR scanner (rapid vs. qualify mode) |
|
||||
| `ae_comp__lead_manual_search.svelte` | Manual badge search + add |
|
||||
| `ae_comp__exhibit_tracking_search.svelte` | Lead list search/filter bar |
|
||||
| `ae_comp__exhibit_tracking_obj_li.svelte` | Lead list item renderer |
|
||||
| `ae_comp__exhibit_license_list.svelte` | License slot manager (admin) |
|
||||
| `ae_comp__exhibit_custom_questions.svelte` | Custom question config editor (admin) |
|
||||
| `ae_comp__exhibit_payment.svelte` | **STUB** — Stripe placeholder, not functional |
|
||||
| `ae_comp__exhibit_search.svelte` | Exhibit search input on the landing page |
|
||||
|
||||
### Lead detail components (within `lead/[exhibit_tracking_id]/`)
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| File | Role |
|
||||
| ---------------------------------- | ------------------------------- |
|
||||
| `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
|
||||
|
||||
### `event_exhibit`
|
||||
|
||||
Represents one exhibitor's presence at an event.
|
||||
Key fields: `event_exhibit_id`, `name`, `code` (booth #), `staff_passcode`, `priority` (paid flag),
|
||||
`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`.
|
||||
|
||||
### `event_exhibit_tracking`
|
||||
|
||||
One captured lead — links an exhibit to a badge.
|
||||
Key fields: `event_exhibit_tracking_id`, `event_exhibit_id`, `event_badge_id`,
|
||||
`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
|
||||
|
||||
Three auth levels in this module:
|
||||
|
||||
1. **Aether platform auth** (manager_access / trusted_access) — full admin bypass
|
||||
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
|
||||
@@ -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`.
|
||||
|
||||
Two scan modes (toggled per exhibit):
|
||||
|
||||
- **Rapid** — auto-resets after 2 seconds to scan the next person
|
||||
- **Qualify** — navigates to lead detail immediately to fill in notes/responses
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
// Basic layout for the leads module
|
||||
let { children }: Props = $props();
|
||||
// Basic layout for the leads module
|
||||
</script>
|
||||
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
@@ -22,4 +22,4 @@ export async function load({ params, parent }) {
|
||||
return {
|
||||
...parent_data
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,254 +1,258 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import {
|
||||
events_loc,
|
||||
events_sess,
|
||||
events_slct
|
||||
} from '$lib/stores/ae_events_stores';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { page } from '$app/state';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { LoaderCircle, Store } from '@lucide/svelte';
|
||||
import Comp_exhibit_search from './ae_comp__exhibit_search.svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import {
|
||||
events_loc,
|
||||
events_sess,
|
||||
events_slct
|
||||
} from '$lib/stores/ae_events_stores';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { page } from '$app/state';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { LoaderCircle, Store } from '@lucide/svelte';
|
||||
import Comp_exhibit_search from './ae_comp__exhibit_search.svelte';
|
||||
|
||||
// *** Initialization & Store Guard ***
|
||||
if ($events_loc.leads) {
|
||||
if (typeof $events_loc.leads.search_version === 'undefined')
|
||||
$events_loc.leads.search_version = 0;
|
||||
if (typeof $events_loc.leads.qry__remote_first === 'undefined')
|
||||
$events_loc.leads.qry__remote_first = false;
|
||||
if (typeof $events_loc.leads.qry__search_text === 'undefined')
|
||||
$events_loc.leads.qry__search_text = '';
|
||||
if (typeof $events_loc.leads.qry__sort_order === 'undefined')
|
||||
$events_loc.leads.qry__sort_order = 'name_asc';
|
||||
}
|
||||
// *** Initialization & Store Guard ***
|
||||
if ($events_loc.leads) {
|
||||
if (typeof $events_loc.leads.search_version === 'undefined')
|
||||
$events_loc.leads.search_version = 0;
|
||||
if (typeof $events_loc.leads.qry__remote_first === 'undefined')
|
||||
$events_loc.leads.qry__remote_first = false;
|
||||
if (typeof $events_loc.leads.qry__search_text === 'undefined')
|
||||
$events_loc.leads.qry__search_text = '';
|
||||
if (typeof $events_loc.leads.qry__sort_order === 'undefined')
|
||||
$events_loc.leads.qry__sort_order = 'name_asc';
|
||||
}
|
||||
|
||||
let exhibit_id_li: Array<string> = $state([]);
|
||||
let search_debounce_timer: any = null;
|
||||
let last_search_id = 0;
|
||||
let last_executed_key = '';
|
||||
let log_lvl = 0;
|
||||
let exhibit_id_li: Array<string> = $state([]);
|
||||
let search_debounce_timer: any = null;
|
||||
let last_search_id = 0;
|
||||
let last_executed_key = '';
|
||||
let log_lvl = 0;
|
||||
|
||||
// Stable LiveQuery Pattern
|
||||
let lq__event_exhibit_obj_li = $derived.by(() => {
|
||||
const ids = exhibit_id_li;
|
||||
const event_id = page.params.event_id;
|
||||
// Stable LiveQuery Pattern
|
||||
let lq__event_exhibit_obj_li = $derived.by(() => {
|
||||
const ids = exhibit_id_li;
|
||||
const event_id = page.params.event_id;
|
||||
|
||||
return liveQuery(async () => {
|
||||
// SCENARIO 1: Specific IDs provided (Search Results)
|
||||
if (Array.isArray(ids) && ids.length > 0) {
|
||||
const results = await db_events.exhibit.bulkGet(ids);
|
||||
return results.filter((item) => item !== undefined);
|
||||
}
|
||||
|
||||
// SCENARIO 2: Fallback broad search
|
||||
if (event_id && !$events_loc.leads.qry__search_text) {
|
||||
return await db_events.exhibit
|
||||
.where('event_id')
|
||||
.equals(event_id)
|
||||
.sortBy('name');
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
});
|
||||
|
||||
// Standardized Reactive Search Pattern
|
||||
let search_params = $derived({
|
||||
v: $events_loc.leads.search_version,
|
||||
str: ($events_loc.leads.qry__search_text ?? '').toLowerCase().trim(),
|
||||
sort: $events_loc.leads.qry__sort_order,
|
||||
event_id: page.params.event_id,
|
||||
remote_first: $events_loc.leads.qry__remote_first
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const params = search_params;
|
||||
if (search_debounce_timer) clearTimeout(search_debounce_timer);
|
||||
search_debounce_timer = setTimeout(() => {
|
||||
untrack(() => {
|
||||
handle_search_refresh(params);
|
||||
});
|
||||
}, 300);
|
||||
return () => {
|
||||
if (search_debounce_timer) clearTimeout(search_debounce_timer);
|
||||
};
|
||||
});
|
||||
|
||||
async function handle_search_refresh(params: any) {
|
||||
const qry_key = JSON.stringify(params);
|
||||
if (qry_key === last_executed_key) return;
|
||||
last_executed_key = qry_key;
|
||||
|
||||
const current_search_id = ++last_search_id;
|
||||
const event_id = params.event_id;
|
||||
const remote_first = params.remote_first;
|
||||
const qry_str = params.str;
|
||||
|
||||
if (!event_id) return;
|
||||
|
||||
// --- Search Constraint: Min 3 characters for non-trusted users ---
|
||||
if (!$ae_loc.trusted_access && qry_str.length < 3) {
|
||||
if (log_lvl) console.log('🛑 [Trace] Search string too short for public user.');
|
||||
untrack(() => {
|
||||
exhibit_id_li = [];
|
||||
$events_sess.leads.submit_status__search = 'idle';
|
||||
});
|
||||
return;
|
||||
return liveQuery(async () => {
|
||||
// SCENARIO 1: Specific IDs provided (Search Results)
|
||||
if (Array.isArray(ids) && ids.length > 0) {
|
||||
const results = await db_events.exhibit.bulkGet(ids);
|
||||
return results.filter((item) => item !== undefined);
|
||||
}
|
||||
|
||||
if (log_lvl) console.log(`🔎 [Trace] Exhibit Search #${current_search_id}: START (remote=${remote_first}, event=${event_id}, str=${params.str})`);
|
||||
// SCENARIO 2: Fallback broad search
|
||||
if (event_id && !$events_loc.leads.qry__search_text) {
|
||||
return await db_events.exhibit
|
||||
.where('event_id')
|
||||
.equals(event_id)
|
||||
.sortBy('name');
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
});
|
||||
|
||||
// Standardized Reactive Search Pattern
|
||||
let search_params = $derived({
|
||||
v: $events_loc.leads.search_version,
|
||||
str: ($events_loc.leads.qry__search_text ?? '').toLowerCase().trim(),
|
||||
sort: $events_loc.leads.qry__sort_order,
|
||||
event_id: page.params.event_id,
|
||||
remote_first: $events_loc.leads.qry__remote_first
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const params = search_params;
|
||||
if (search_debounce_timer) clearTimeout(search_debounce_timer);
|
||||
search_debounce_timer = setTimeout(() => {
|
||||
untrack(() => {
|
||||
$events_sess.leads.submit_status__search = 'searching';
|
||||
handle_search_refresh(params);
|
||||
});
|
||||
}, 300);
|
||||
return () => {
|
||||
if (search_debounce_timer) clearTimeout(search_debounce_timer);
|
||||
};
|
||||
});
|
||||
|
||||
// 1. FAST PATH: Local IDB Search
|
||||
if (!remote_first) {
|
||||
try {
|
||||
let local_results = await db_events.exhibit
|
||||
.where('event_id')
|
||||
.equals(event_id)
|
||||
.filter((exhibit) => {
|
||||
// Priority Filter for Public
|
||||
if (!$ae_loc.manager_access && !exhibit.priority) return false;
|
||||
async function handle_search_refresh(params: any) {
|
||||
const qry_key = JSON.stringify(params);
|
||||
if (qry_key === last_executed_key) return;
|
||||
last_executed_key = qry_key;
|
||||
|
||||
if (qry_str) {
|
||||
const name = (exhibit.name ?? '').toLowerCase();
|
||||
const code = (exhibit.code ?? '').toLowerCase();
|
||||
if (
|
||||
!name.includes(qry_str) &&
|
||||
!code.includes(qry_str)
|
||||
)
|
||||
return false;
|
||||
} else if (!$ae_loc.trusted_access) {
|
||||
// Don't show default results to public if no search string
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.toArray();
|
||||
const current_search_id = ++last_search_id;
|
||||
const event_id = params.event_id;
|
||||
const remote_first = params.remote_first;
|
||||
const qry_str = params.str;
|
||||
|
||||
local_results.sort((a, b) => {
|
||||
switch (params.sort) {
|
||||
case 'name_asc':
|
||||
return (a.name ?? '').localeCompare(b.name ?? '');
|
||||
case 'name_desc':
|
||||
return (b.name ?? '').localeCompare(a.name ?? '');
|
||||
case 'code_asc':
|
||||
return (a.code ?? '').localeCompare(b.code ?? '');
|
||||
case 'code_desc':
|
||||
return (b.code ?? '').localeCompare(a.code ?? '');
|
||||
case 'updated_desc':
|
||||
return (
|
||||
new Date(b.updated_on || 0).getTime() -
|
||||
new Date(a.updated_on || 0).getTime()
|
||||
);
|
||||
default:
|
||||
return (a.name ?? '').localeCompare(b.name ?? '');
|
||||
}
|
||||
});
|
||||
if (!event_id) return;
|
||||
|
||||
const local_ids = local_results
|
||||
.map((e) => String(e.id || e.event_exhibit_id))
|
||||
.filter(Boolean);
|
||||
// --- Search Constraint: Min 3 characters for non-trusted users ---
|
||||
if (!$ae_loc.trusted_access && qry_str.length < 3) {
|
||||
if (log_lvl)
|
||||
console.log('🛑 [Trace] Search string too short for public user.');
|
||||
untrack(() => {
|
||||
exhibit_id_li = [];
|
||||
$events_sess.leads.submit_status__search = 'idle';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (current_search_id === last_search_id) {
|
||||
if (log_lvl) console.log(`✅ [Trace] Exhibit Search #${current_search_id}: Local path found ${local_ids.length} items.`);
|
||||
untrack(() => {
|
||||
exhibit_id_li = local_ids;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Exhibit Local Search failed.', e);
|
||||
}
|
||||
}
|
||||
if (log_lvl)
|
||||
console.log(
|
||||
`🔎 [Trace] Exhibit Search #${current_search_id}: START (remote=${remote_first}, event=${event_id}, str=${params.str})`
|
||||
);
|
||||
|
||||
// 2. REVALIDATE: API Request
|
||||
untrack(() => {
|
||||
$events_sess.leads.submit_status__search = 'searching';
|
||||
});
|
||||
|
||||
// 1. FAST PATH: Local IDB Search
|
||||
if (!remote_first) {
|
||||
try {
|
||||
let order_by_li: any = {};
|
||||
switch (params.sort) {
|
||||
case 'name_asc':
|
||||
order_by_li = { name: 'ASC' };
|
||||
break;
|
||||
case 'name_desc':
|
||||
order_by_li = { name: 'DESC' };
|
||||
break;
|
||||
case 'code_asc':
|
||||
order_by_li = { code: 'ASC' };
|
||||
break;
|
||||
case 'code_desc':
|
||||
order_by_li = { code: 'DESC' };
|
||||
break;
|
||||
case 'updated_desc':
|
||||
order_by_li = { updated_on: 'DESC' };
|
||||
break;
|
||||
default:
|
||||
order_by_li = { name: 'ASC' };
|
||||
}
|
||||
let local_results = await db_events.exhibit
|
||||
.where('event_id')
|
||||
.equals(event_id)
|
||||
.filter((exhibit) => {
|
||||
// Priority Filter for Public
|
||||
if (!$ae_loc.manager_access && !exhibit.priority)
|
||||
return false;
|
||||
|
||||
const results = await events_func.search__exhibit({
|
||||
api_cfg: $ae_api,
|
||||
event_id: event_id,
|
||||
fulltext_search_qry_str: qry_str || null,
|
||||
priority: $ae_loc.manager_access ? 'all' : 'priority',
|
||||
order_by_li,
|
||||
limit: 100
|
||||
if (qry_str) {
|
||||
const name = (exhibit.name ?? '').toLowerCase();
|
||||
const code = (exhibit.code ?? '').toLowerCase();
|
||||
if (!name.includes(qry_str) && !code.includes(qry_str))
|
||||
return false;
|
||||
} else if (!$ae_loc.trusted_access) {
|
||||
// Don't show default results to public if no search string
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.toArray();
|
||||
|
||||
local_results.sort((a, b) => {
|
||||
switch (params.sort) {
|
||||
case 'name_asc':
|
||||
return (a.name ?? '').localeCompare(b.name ?? '');
|
||||
case 'name_desc':
|
||||
return (b.name ?? '').localeCompare(a.name ?? '');
|
||||
case 'code_asc':
|
||||
return (a.code ?? '').localeCompare(b.code ?? '');
|
||||
case 'code_desc':
|
||||
return (b.code ?? '').localeCompare(a.code ?? '');
|
||||
case 'updated_desc':
|
||||
return (
|
||||
new Date(b.updated_on || 0).getTime() -
|
||||
new Date(a.updated_on || 0).getTime()
|
||||
);
|
||||
default:
|
||||
return (a.name ?? '').localeCompare(b.name ?? '');
|
||||
}
|
||||
});
|
||||
|
||||
const local_ids = local_results
|
||||
.map((e) => String(e.id || e.event_exhibit_id))
|
||||
.filter(Boolean);
|
||||
|
||||
if (current_search_id === last_search_id) {
|
||||
const api_ids = results
|
||||
.map((e: any) => String(e.id || e.event_exhibit_id))
|
||||
.filter(Boolean);
|
||||
|
||||
if (log_lvl) console.log(`📦 [Trace] Exhibit Search #${current_search_id}: API revalidation found ${api_ids.length} items.`);
|
||||
|
||||
if (log_lvl)
|
||||
console.log(
|
||||
`✅ [Trace] Exhibit Search #${current_search_id}: Local path found ${local_ids.length} items.`
|
||||
);
|
||||
untrack(() => {
|
||||
exhibit_id_li = api_ids;
|
||||
$events_sess.leads.submit_status__search = 'done';
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (current_search_id === last_search_id) {
|
||||
console.error('Exhibit revalidation failed:', error);
|
||||
untrack(() => {
|
||||
$events_sess.leads.submit_status__search = 'error';
|
||||
exhibit_id_li = local_ids;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Exhibit Local Search failed.', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. REVALIDATE: API Request
|
||||
try {
|
||||
let order_by_li: any = {};
|
||||
switch (params.sort) {
|
||||
case 'name_asc':
|
||||
order_by_li = { name: 'ASC' };
|
||||
break;
|
||||
case 'name_desc':
|
||||
order_by_li = { name: 'DESC' };
|
||||
break;
|
||||
case 'code_asc':
|
||||
order_by_li = { code: 'ASC' };
|
||||
break;
|
||||
case 'code_desc':
|
||||
order_by_li = { code: 'DESC' };
|
||||
break;
|
||||
case 'updated_desc':
|
||||
order_by_li = { updated_on: 'DESC' };
|
||||
break;
|
||||
default:
|
||||
order_by_li = { name: 'ASC' };
|
||||
}
|
||||
|
||||
const results = await events_func.search__exhibit({
|
||||
api_cfg: $ae_api,
|
||||
event_id: event_id,
|
||||
fulltext_search_qry_str: qry_str || null,
|
||||
priority: $ae_loc.manager_access ? 'all' : 'priority',
|
||||
order_by_li,
|
||||
limit: 100
|
||||
});
|
||||
|
||||
if (current_search_id === last_search_id) {
|
||||
const api_ids = results
|
||||
.map((e: any) => String(e.id || e.event_exhibit_id))
|
||||
.filter(Boolean);
|
||||
|
||||
if (log_lvl)
|
||||
console.log(
|
||||
`📦 [Trace] Exhibit Search #${current_search_id}: API revalidation found ${api_ids.length} items.`
|
||||
);
|
||||
|
||||
untrack(() => {
|
||||
exhibit_id_li = api_ids;
|
||||
$events_sess.leads.submit_status__search = 'done';
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (current_search_id === last_search_id) {
|
||||
console.error('Exhibit revalidation failed:', error);
|
||||
untrack(() => {
|
||||
$events_sess.leads.submit_status__search = 'error';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<Comp_exhibit_search event_id={page.params.event_id ?? ''} />
|
||||
|
||||
{#if $events_sess.leads.submit_status__search === 'searching' && exhibit_id_li.length === 0}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-10 opacity-50 text-center"
|
||||
>
|
||||
<LoaderCircle size="3em" class="animate-spin mb-4 mx-auto" />
|
||||
class="flex flex-col items-center justify-center p-10 text-center opacity-50">
|
||||
<LoaderCircle size="3em" class="mx-auto mb-4 animate-spin" />
|
||||
<p class="text-xl">Searching exhibits...</p>
|
||||
</div>
|
||||
{:else if $lq__event_exhibit_obj_li && $lq__event_exhibit_obj_li.length > 0}
|
||||
<h2 class="h3">Select your exhibit from the list</h2>
|
||||
<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)}
|
||||
<!-- 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
|
||||
href="/events/{page.params
|
||||
.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" />
|
||||
<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">
|
||||
Booth #{exhibit_obj.code}
|
||||
</div>
|
||||
@@ -256,6 +260,6 @@
|
||||
{/each}
|
||||
</div>
|
||||
{: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}
|
||||
</section>
|
||||
|
||||
@@ -21,4 +21,4 @@ export async function load({ params, parent }) {
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,46 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
event_id: string;
|
||||
log_lvl?: number;
|
||||
interface Props {
|
||||
event_id: string;
|
||||
log_lvl?: number;
|
||||
}
|
||||
|
||||
let { event_id, log_lvl = 0 }: Props = $props();
|
||||
|
||||
// *** Import other supporting libraries
|
||||
import {
|
||||
Library,
|
||||
LoaderCircle,
|
||||
RemoveFormatting,
|
||||
Search
|
||||
} from '@lucide/svelte';
|
||||
import { ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
|
||||
|
||||
function handle_search_trigger() {
|
||||
if ($events_loc.leads.search_version === undefined) {
|
||||
$events_loc.leads.search_version = 0;
|
||||
}
|
||||
$events_loc.leads.search_version++;
|
||||
}
|
||||
|
||||
let { event_id, log_lvl = 0 }: Props = $props();
|
||||
|
||||
// *** Import other supporting libraries
|
||||
import { Library, LoaderCircle, RemoveFormatting, Search } from '@lucide/svelte';
|
||||
import { ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
|
||||
|
||||
function handle_search_trigger() {
|
||||
if ($events_loc.leads.search_version === undefined) {
|
||||
$events_loc.leads.search_version = 0;
|
||||
}
|
||||
$events_loc.leads.search_version++;
|
||||
}
|
||||
|
||||
function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
return function (event: T) {
|
||||
event.preventDefault();
|
||||
fn(event);
|
||||
};
|
||||
}
|
||||
function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
return function (event: T) {
|
||||
event.preventDefault();
|
||||
fn(event);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<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
|
||||
onsubmit={prevent_default(() => {
|
||||
handle_search_trigger();
|
||||
})}
|
||||
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
|
||||
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
|
||||
type="search"
|
||||
placeholder="Exhibitor name or code..."
|
||||
@@ -46,20 +48,18 @@
|
||||
bind:value={$events_loc.leads.qry__search_text}
|
||||
autocomplete="off"
|
||||
data-lpignore="true"
|
||||
class="input text-lg font-mono grow transition-all"
|
||||
class="input grow font-mono text-lg transition-all"
|
||||
onkeyup={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handle_search_trigger();
|
||||
}
|
||||
}}
|
||||
title="Search by name or code. Press Enter."
|
||||
/>
|
||||
title="Search by name or code. Press Enter." />
|
||||
|
||||
<select
|
||||
bind:value={$events_loc.leads.qry__sort_order}
|
||||
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_desc">Name DESC</option>
|
||||
<option value="code_asc">Booth # ASC</option>
|
||||
@@ -71,10 +71,9 @@
|
||||
<div class="flex flex-row items-center justify-center gap-1">
|
||||
<button
|
||||
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'}
|
||||
<LoaderCircle class="animate-spin mx-1" />
|
||||
<LoaderCircle class="mx-1 animate-spin" />
|
||||
{:else}
|
||||
<Search class="mx-1" />
|
||||
{/if}
|
||||
@@ -88,9 +87,8 @@
|
||||
$events_loc.leads.qry__search_text = '';
|
||||
handle_search_trigger();
|
||||
}}
|
||||
class="btn btn-sm text-xs preset-outlined-tertiary-100-900 hover:preset-filled-tertiary-100-900 transition-all"
|
||||
title="Clear search query"
|
||||
>
|
||||
class="btn btn-sm preset-outlined-tertiary-100-900 hover:preset-filled-tertiary-100-900 text-xs transition-all"
|
||||
title="Clear search query">
|
||||
<RemoveFormatting size="1.25em" />
|
||||
<span class="hidden md:inline"> Clear </span>
|
||||
</button>
|
||||
@@ -98,19 +96,16 @@
|
||||
</form>
|
||||
|
||||
<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}
|
||||
<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>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={$events_loc.leads.qry__remote_first}
|
||||
onchange={handle_search_trigger}
|
||||
class="checkbox checkbox-sm"
|
||||
/>
|
||||
class="checkbox checkbox-sm" />
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/+layout.svelte
|
||||
* Exhibitor Dashboard Layout.
|
||||
*/
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/+layout.svelte
|
||||
* Exhibitor Dashboard Layout.
|
||||
*/
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
let { children }: Props = $props();
|
||||
|
||||
import { events_slct } from '$lib/stores/ae_events_stores';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { events_slct } from '$lib/stores/ae_events_stores';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
|
||||
let lq__exhibit_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!$events_slct.exhibit_id) return null;
|
||||
return await db_events.exhibit.get($events_slct.exhibit_id);
|
||||
})
|
||||
);
|
||||
let lq__exhibit_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!$events_slct.exhibit_id) return null;
|
||||
return await db_events.exhibit.get($events_slct.exhibit_id);
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- <div class="exhibit-layout flex flex-col h-full w-full"> -->
|
||||
{@render children?.()}
|
||||
{@render children?.()}
|
||||
<!-- </div> -->
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function load({ params, parent }) {
|
||||
const exhibit_id = params.exhibit_id;
|
||||
|
||||
// Sync to store for components
|
||||
events_slct.update(s => {
|
||||
events_slct.update((s) => {
|
||||
s.exhibit_id = exhibit_id;
|
||||
return s;
|
||||
});
|
||||
@@ -34,4 +34,4 @@ export async function load({ params, parent }) {
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,402 +1,427 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import {
|
||||
events_loc,
|
||||
events_sess,
|
||||
events_slct
|
||||
} from '$lib/stores/ae_events_stores';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { page } from '$app/state';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
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_obj_li from './ae_comp__exhibit_tracking_obj_li.svelte';
|
||||
import Tab_add from './ae_tab__add.svelte';
|
||||
import Tab_start from './ae_tab__start.svelte';
|
||||
import Tab_manage from './ae_tab__manage.svelte';
|
||||
import Comp_exhibit_payment from './ae_comp__exhibit_payment.svelte';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import {
|
||||
events_loc,
|
||||
events_sess,
|
||||
events_slct
|
||||
} from '$lib/stores/ae_events_stores';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { page } from '$app/state';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
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_obj_li from './ae_comp__exhibit_tracking_obj_li.svelte';
|
||||
import Tab_add from './ae_tab__add.svelte';
|
||||
import Tab_start from './ae_tab__start.svelte';
|
||||
import Tab_manage from './ae_tab__manage.svelte';
|
||||
import Comp_exhibit_payment from './ae_comp__exhibit_payment.svelte';
|
||||
|
||||
// *** Initialization & Store Guard ***
|
||||
if ($events_loc.leads) {
|
||||
if (typeof $events_loc.leads.tracking__search_version === 'undefined')
|
||||
$events_loc.leads.tracking__search_version = 0;
|
||||
if (
|
||||
typeof $events_loc.leads.tracking__qry__remote_first === 'undefined'
|
||||
)
|
||||
$events_loc.leads.tracking__qry__remote_first = false;
|
||||
if (typeof $events_loc.leads.tracking__qry__search_text === 'undefined')
|
||||
$events_loc.leads.tracking__qry__search_text = '';
|
||||
if (typeof $events_loc.leads.tracking__qry__sort_order === 'undefined')
|
||||
$events_loc.leads.tracking__qry__sort_order = 'created_desc';
|
||||
if (typeof $events_loc.leads.refresh_interval_sec === 'undefined')
|
||||
$events_loc.leads.refresh_interval_sec = 25;
|
||||
if (typeof $events_loc.leads.show_hidden === 'undefined')
|
||||
$events_loc.leads.show_hidden = false;
|
||||
// *** Initialization & Store Guard ***
|
||||
if ($events_loc.leads) {
|
||||
if (typeof $events_loc.leads.tracking__search_version === 'undefined')
|
||||
$events_loc.leads.tracking__search_version = 0;
|
||||
if (typeof $events_loc.leads.tracking__qry__remote_first === 'undefined')
|
||||
$events_loc.leads.tracking__qry__remote_first = false;
|
||||
if (typeof $events_loc.leads.tracking__qry__search_text === 'undefined')
|
||||
$events_loc.leads.tracking__qry__search_text = '';
|
||||
if (typeof $events_loc.leads.tracking__qry__sort_order === 'undefined')
|
||||
$events_loc.leads.tracking__qry__sort_order = 'created_desc';
|
||||
if (typeof $events_loc.leads.refresh_interval_sec === 'undefined')
|
||||
$events_loc.leads.refresh_interval_sec = 25;
|
||||
if (typeof $events_loc.leads.show_hidden === 'undefined')
|
||||
$events_loc.leads.show_hidden = false;
|
||||
}
|
||||
|
||||
// --- Sign-In State (Derived) ---
|
||||
// 1. Manager Access (Bypass) OR 2. Valid Exhibit Auth entry
|
||||
let is_signed_in = $derived(
|
||||
$ae_loc.manager_access ||
|
||||
!!$events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? '']
|
||||
);
|
||||
|
||||
// --- Tab State (Sticky via Store) ---
|
||||
let active_tab = $derived.by(() => {
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
if (!exhibit_id) return 'start';
|
||||
const saved_tab = $events_loc.leads.tab?.[exhibit_id] ?? 'list';
|
||||
// If signed in but stuck on start tab, go to list
|
||||
if (is_signed_in && saved_tab === 'start') return 'list';
|
||||
return saved_tab;
|
||||
});
|
||||
let previous_main_tab = $state('list'); // To remember if we were on 'add' or 'list' before going to 'manage'
|
||||
|
||||
function set_active_tab(new_tab: string) {
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
if (!exhibit_id) return;
|
||||
if (!$events_loc.leads.tab) $events_loc.leads.tab = {};
|
||||
$events_loc.leads.tab[exhibit_id] = new_tab;
|
||||
}
|
||||
|
||||
let tracking_id_li: Array<string> = $state([]);
|
||||
let search_debounce_timer: any = null;
|
||||
let last_search_id = 0;
|
||||
let last_executed_key = '';
|
||||
let log_lvl = 0;
|
||||
|
||||
// --- NEW: Direct Reactive List Pattern ---
|
||||
let raw_lead_li: any[] = $state([]);
|
||||
|
||||
// Final filtered list that the UI actually sees
|
||||
// Applying the HARD GUARD here ensures that no matter where the data came from
|
||||
// (API or IDB), it MUST match the selected licensee.
|
||||
let filtered_lead_li = $derived.by(() => {
|
||||
const licensee_filter = search_params.licensee_email;
|
||||
const show_hidden = search_params.show_hidden;
|
||||
|
||||
return raw_lead_li.filter((lead) => {
|
||||
// Never show disabled (removed) leads — enable=0/false means the exhibitor deleted them
|
||||
if (lead.enable === 0 || lead.enable === false) return false;
|
||||
// Exclude hidden leads unless show_hidden is toggled on
|
||||
if (!show_hidden && lead.hide) return false;
|
||||
if (licensee_filter === 'all') return true;
|
||||
const capturer = lead.external_person_id || lead.group;
|
||||
return capturer === licensee_filter;
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to the Lead List
|
||||
$effect(() => {
|
||||
const ids = tracking_id_li;
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
const has_search = !!$events_loc.leads.tracking__qry__search_text;
|
||||
|
||||
const observable = liveQuery(async () => {
|
||||
// 1. Specific IDs provided (from API Search or Manual Entry)
|
||||
if (Array.isArray(ids) && ids.length > 0) {
|
||||
const results = await db_events.exhibit_tracking.bulkGet(ids);
|
||||
return results.filter((item) => item !== undefined);
|
||||
}
|
||||
|
||||
// 2. Fallback broad search (Initial load or no search text)
|
||||
if (exhibit_id && !has_search) {
|
||||
return await db_events.exhibit_tracking
|
||||
.where('event_exhibit_id')
|
||||
.equals(exhibit_id)
|
||||
.reverse()
|
||||
.sortBy('created_on');
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
const subscription = observable.subscribe((res) => {
|
||||
raw_lead_li = res;
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
|
||||
// Exhibit Info
|
||||
const lq__exhibit_obj = liveQuery(() => {
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
if (!exhibit_id) return undefined;
|
||||
return db_events.exhibit.get(exhibit_id);
|
||||
});
|
||||
|
||||
// Standardized Reactive Search Pattern
|
||||
let search_params = $derived.by(() => {
|
||||
let licensee_email = $events_loc.leads.tracking__qry__licensee_email;
|
||||
|
||||
// Resolve "My Leads" to actual email
|
||||
if (licensee_email === 'my') {
|
||||
licensee_email =
|
||||
$events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? '']
|
||||
?.key || 'all';
|
||||
}
|
||||
|
||||
// --- Sign-In State (Derived) ---
|
||||
// 1. Manager Access (Bypass) OR 2. Valid Exhibit Auth entry
|
||||
let is_signed_in = $derived(
|
||||
$ae_loc.manager_access ||
|
||||
!!$events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? '']
|
||||
return {
|
||||
v: $events_loc.leads.tracking__search_version,
|
||||
str: ($events_loc.leads.tracking__qry__search_text ?? '')
|
||||
.toLowerCase()
|
||||
.trim(),
|
||||
sort: $events_loc.leads.tracking__qry__sort_order,
|
||||
licensee_email: licensee_email,
|
||||
exhibit_id: page.params.exhibit_id,
|
||||
remote_first: $events_loc.leads.tracking__qry__remote_first,
|
||||
show_hidden: $events_loc.leads.show_hidden ?? false
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const params = search_params;
|
||||
if (search_debounce_timer) clearTimeout(search_debounce_timer);
|
||||
search_debounce_timer = setTimeout(() => {
|
||||
untrack(() => {
|
||||
handle_search_refresh(params);
|
||||
// Reset countdown on manual search
|
||||
$events_sess.leads.next_refresh_countdown =
|
||||
$events_loc.leads.refresh_interval_sec || 25;
|
||||
});
|
||||
}, 300);
|
||||
return () => {
|
||||
if (search_debounce_timer) clearTimeout(search_debounce_timer);
|
||||
};
|
||||
});
|
||||
|
||||
// --- Auto-Refresh Timer Logic ---
|
||||
$effect(() => {
|
||||
if (!is_signed_in) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
untrack(() => {
|
||||
if ($events_sess.leads.next_refresh_countdown > 0) {
|
||||
$events_sess.leads.next_refresh_countdown--;
|
||||
} else {
|
||||
// Trigger refresh
|
||||
$events_loc.leads.tracking__search_version++;
|
||||
$events_sess.leads.next_refresh_countdown =
|
||||
$events_loc.leads.refresh_interval_sec || 25;
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
async function handle_search_refresh(params: any) {
|
||||
const qry_key = JSON.stringify(params);
|
||||
if (qry_key === last_executed_key) return;
|
||||
last_executed_key = qry_key;
|
||||
|
||||
const current_search_id = ++last_search_id;
|
||||
const exhibit_id = params.exhibit_id;
|
||||
const remote_first = params.remote_first;
|
||||
|
||||
if (!exhibit_id) return;
|
||||
|
||||
if (log_lvl)
|
||||
console.log(
|
||||
`🔎 [Trace] Lead Search #${current_search_id}: START (remote=${remote_first}, exhibit=${exhibit_id}, str=${params.str})`
|
||||
);
|
||||
|
||||
// --- Tab State (Sticky via Store) ---
|
||||
let active_tab = $derived.by(() => {
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
if (!exhibit_id) return 'start';
|
||||
const saved_tab = $events_loc.leads.tab?.[exhibit_id] ?? 'list';
|
||||
// If signed in but stuck on start tab, go to list
|
||||
if (is_signed_in && saved_tab === 'start') return 'list';
|
||||
return saved_tab;
|
||||
});
|
||||
let previous_main_tab = $state('list'); // To remember if we were on 'add' or 'list' before going to 'manage'
|
||||
|
||||
function set_active_tab(new_tab: string) {
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
if (!exhibit_id) return;
|
||||
if (!$events_loc.leads.tab) $events_loc.leads.tab = {};
|
||||
$events_loc.leads.tab[exhibit_id] = new_tab;
|
||||
}
|
||||
|
||||
let tracking_id_li: Array<string> = $state([]);
|
||||
let search_debounce_timer: any = null;
|
||||
let last_search_id = 0;
|
||||
let last_executed_key = '';
|
||||
let log_lvl = 0;
|
||||
|
||||
// --- NEW: Direct Reactive List Pattern ---
|
||||
let raw_lead_li: any[] = $state([]);
|
||||
|
||||
// Final filtered list that the UI actually sees
|
||||
// Applying the HARD GUARD here ensures that no matter where the data came from
|
||||
// (API or IDB), it MUST match the selected licensee.
|
||||
let filtered_lead_li = $derived.by(() => {
|
||||
const licensee_filter = search_params.licensee_email;
|
||||
const show_hidden = search_params.show_hidden;
|
||||
|
||||
return raw_lead_li.filter(lead => {
|
||||
// Never show disabled (removed) leads — enable=0/false means the exhibitor deleted them
|
||||
if (lead.enable === 0 || lead.enable === false) return false;
|
||||
// Exclude hidden leads unless show_hidden is toggled on
|
||||
if (!show_hidden && lead.hide) return false;
|
||||
if (licensee_filter === 'all') return true;
|
||||
const capturer = lead.external_person_id || lead.group;
|
||||
return capturer === licensee_filter;
|
||||
});
|
||||
untrack(() => {
|
||||
$events_sess.leads.submit_status__search = 'searching';
|
||||
});
|
||||
|
||||
// Subscribe to the Lead List
|
||||
$effect(() => {
|
||||
const ids = tracking_id_li;
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
const has_search = !!$events_loc.leads.tracking__qry__search_text;
|
||||
const qry_str = params.str;
|
||||
|
||||
const observable = liveQuery(async () => {
|
||||
// 1. Specific IDs provided (from API Search or Manual Entry)
|
||||
if (Array.isArray(ids) && ids.length > 0) {
|
||||
const results = await db_events.exhibit_tracking.bulkGet(ids);
|
||||
return results.filter((item) => item !== undefined);
|
||||
}
|
||||
|
||||
// 2. Fallback broad search (Initial load or no search text)
|
||||
if (exhibit_id && !has_search) {
|
||||
return await db_events.exhibit_tracking
|
||||
.where('event_exhibit_id')
|
||||
.equals(exhibit_id)
|
||||
.reverse()
|
||||
.sortBy('created_on');
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
const subscription = observable.subscribe(res => {
|
||||
raw_lead_li = res;
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
|
||||
// Exhibit Info
|
||||
const lq__exhibit_obj = liveQuery(() => {
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
if (!exhibit_id) return undefined;
|
||||
return db_events.exhibit.get(exhibit_id);
|
||||
});
|
||||
|
||||
// Standardized Reactive Search Pattern
|
||||
let search_params = $derived.by(() => {
|
||||
let licensee_email = $events_loc.leads.tracking__qry__licensee_email;
|
||||
|
||||
// Resolve "My Leads" to actual email
|
||||
if (licensee_email === 'my') {
|
||||
licensee_email = $events_loc.leads.auth_exhibit_kv?.[page.params.exhibit_id ?? '']?.key || 'all';
|
||||
}
|
||||
|
||||
return {
|
||||
v: $events_loc.leads.tracking__search_version,
|
||||
str: ($events_loc.leads.tracking__qry__search_text ?? '')
|
||||
.toLowerCase()
|
||||
.trim(),
|
||||
sort: $events_loc.leads.tracking__qry__sort_order,
|
||||
licensee_email: licensee_email,
|
||||
exhibit_id: page.params.exhibit_id,
|
||||
remote_first: $events_loc.leads.tracking__qry__remote_first,
|
||||
show_hidden: $events_loc.leads.show_hidden ?? false
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const params = search_params;
|
||||
if (search_debounce_timer) clearTimeout(search_debounce_timer);
|
||||
search_debounce_timer = setTimeout(() => {
|
||||
untrack(() => {
|
||||
handle_search_refresh(params);
|
||||
// Reset countdown on manual search
|
||||
$events_sess.leads.next_refresh_countdown = $events_loc.leads.refresh_interval_sec || 25;
|
||||
});
|
||||
}, 300);
|
||||
return () => {
|
||||
if (search_debounce_timer) clearTimeout(search_debounce_timer);
|
||||
};
|
||||
});
|
||||
|
||||
// --- Auto-Refresh Timer Logic ---
|
||||
$effect(() => {
|
||||
if (!is_signed_in) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
untrack(() => {
|
||||
if ($events_sess.leads.next_refresh_countdown > 0) {
|
||||
$events_sess.leads.next_refresh_countdown--;
|
||||
} else {
|
||||
// Trigger refresh
|
||||
$events_loc.leads.tracking__search_version++;
|
||||
$events_sess.leads.next_refresh_countdown = $events_loc.leads.refresh_interval_sec || 25;
|
||||
}
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
async function handle_search_refresh(params: any) {
|
||||
const qry_key = JSON.stringify(params);
|
||||
if (qry_key === last_executed_key) return;
|
||||
last_executed_key = qry_key;
|
||||
|
||||
const current_search_id = ++last_search_id;
|
||||
const exhibit_id = params.exhibit_id;
|
||||
const remote_first = params.remote_first;
|
||||
|
||||
if (!exhibit_id) return;
|
||||
|
||||
if (log_lvl) console.log(`🔎 [Trace] Lead Search #${current_search_id}: START (remote=${remote_first}, exhibit=${exhibit_id}, str=${params.str})`);
|
||||
|
||||
untrack(() => {
|
||||
$events_sess.leads.submit_status__search = 'searching';
|
||||
});
|
||||
|
||||
const qry_str = params.str;
|
||||
|
||||
// 1. FAST PATH: Local IDB Search
|
||||
if (!remote_first) {
|
||||
try {
|
||||
const target_exhibit_id = exhibit_id;
|
||||
const target_licensee_email = params.licensee_email;
|
||||
|
||||
let local_results = await db_events.exhibit_tracking
|
||||
.where('event_exhibit_id')
|
||||
.equals(target_exhibit_id)
|
||||
.filter((tracking) => {
|
||||
// 0. Never include disabled (removed) records — they're soft-deleted
|
||||
if (!tracking.enable) return false;
|
||||
// 1. Hide filter — exclude hidden records unless show_hidden is on
|
||||
if (!params.show_hidden && tracking.hide) return false;
|
||||
|
||||
// 2. Licensee Email Filter
|
||||
if (target_licensee_email !== 'all') {
|
||||
if (tracking.external_person_id !== target_licensee_email) return false;
|
||||
}
|
||||
|
||||
if (qry_str) {
|
||||
const name = (
|
||||
tracking.event_badge_full_name ?? ''
|
||||
).toLowerCase();
|
||||
const email = (
|
||||
tracking.event_badge_email ?? ''
|
||||
).toLowerCase();
|
||||
const notes = ae_util.strip_html(tracking.exhibitor_notes ?? '').toLowerCase();
|
||||
// Guard: Prevent "undefined" from being searched
|
||||
if (tracking.exhibitor_notes === 'undefined') {
|
||||
tracking.exhibitor_notes = '';
|
||||
}
|
||||
const qry_string = (
|
||||
tracking.default_qry_str ?? ''
|
||||
).toLowerCase();
|
||||
|
||||
if (
|
||||
!name.includes(qry_str) &&
|
||||
!email.includes(qry_str) &&
|
||||
!notes.includes(qry_str) &&
|
||||
!qry_string.includes(qry_str)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.toArray();
|
||||
|
||||
local_results.sort((a, b) => {
|
||||
switch (params.sort) {
|
||||
case 'name_asc':
|
||||
return (
|
||||
a.event_badge_full_name ?? ''
|
||||
).localeCompare(b.event_badge_full_name ?? '');
|
||||
case 'name_desc':
|
||||
return (
|
||||
b.event_badge_full_name ?? ''
|
||||
).localeCompare(a.event_badge_full_name ?? '');
|
||||
case 'created_asc':
|
||||
return (
|
||||
new Date(a.created_on || 0).getTime() -
|
||||
new Date(b.created_on || 0).getTime()
|
||||
);
|
||||
case 'created_desc':
|
||||
return (
|
||||
new Date(b.created_on || 0).getTime() -
|
||||
new Date(a.created_on || 0).getTime()
|
||||
);
|
||||
default:
|
||||
return (
|
||||
new Date(b.created_on || 0).getTime() -
|
||||
new Date(a.created_on || 0).getTime()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const local_ids = local_results
|
||||
.map((e) =>
|
||||
String(e.id || e.event_exhibit_tracking_id)
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
if (current_search_id === last_search_id) {
|
||||
if (log_lvl) console.log(`✅ [Trace] Lead Search #${current_search_id}: Local path found ${local_ids.length} items.`);
|
||||
untrack(() => {
|
||||
tracking_id_li = local_ids;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Exhibit Tracking Local Search failed.', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. REVALIDATE: API Request
|
||||
// 1. FAST PATH: Local IDB Search
|
||||
if (!remote_first) {
|
||||
try {
|
||||
let order_by_li: any = {};
|
||||
switch (params.sort) {
|
||||
case 'name_asc':
|
||||
order_by_li = { event_badge_full_name: 'ASC' };
|
||||
break;
|
||||
case 'name_desc':
|
||||
order_by_li = { event_badge_full_name: 'DESC' };
|
||||
break;
|
||||
case 'created_asc':
|
||||
order_by_li = { created_on: 'ASC' };
|
||||
break;
|
||||
case 'created_desc':
|
||||
order_by_li = { created_on: 'DESC' };
|
||||
break;
|
||||
default:
|
||||
order_by_li = { created_on: 'DESC' };
|
||||
}
|
||||
const target_exhibit_id = exhibit_id;
|
||||
const target_licensee_email = params.licensee_email;
|
||||
|
||||
const q_event_id: string = page.params.event_id ?? '';
|
||||
const q_exhibit_id: string = exhibit_id ?? '';
|
||||
const q_licensee_email: string | null = (params.licensee_email !== 'all') ? (params.licensee_email ?? '') : null;
|
||||
let local_results = await db_events.exhibit_tracking
|
||||
.where('event_exhibit_id')
|
||||
.equals(target_exhibit_id)
|
||||
.filter((tracking) => {
|
||||
// 0. Never include disabled (removed) records — they're soft-deleted
|
||||
if (!tracking.enable) return false;
|
||||
// 1. Hide filter — exclude hidden records unless show_hidden is on
|
||||
if (!params.show_hidden && tracking.hide) return false;
|
||||
|
||||
const results = await events_func.search__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
event_id: q_event_id,
|
||||
event_exhibit_id: q_exhibit_id,
|
||||
fulltext_search_qry_str: qry_str || null,
|
||||
qry_external_person_id: q_licensee_email,
|
||||
hidden: params.show_hidden ? 'all' : 'not_hidden',
|
||||
order_by_li,
|
||||
limit: 150
|
||||
// 2. Licensee Email Filter
|
||||
if (target_licensee_email !== 'all') {
|
||||
if (
|
||||
tracking.external_person_id !==
|
||||
target_licensee_email
|
||||
)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (qry_str) {
|
||||
const name = (
|
||||
tracking.event_badge_full_name ?? ''
|
||||
).toLowerCase();
|
||||
const email = (
|
||||
tracking.event_badge_email ?? ''
|
||||
).toLowerCase();
|
||||
const notes = ae_util
|
||||
.strip_html(tracking.exhibitor_notes ?? '')
|
||||
.toLowerCase();
|
||||
// Guard: Prevent "undefined" from being searched
|
||||
if (tracking.exhibitor_notes === 'undefined') {
|
||||
tracking.exhibitor_notes = '';
|
||||
}
|
||||
const qry_string = (
|
||||
tracking.default_qry_str ?? ''
|
||||
).toLowerCase();
|
||||
|
||||
if (
|
||||
!name.includes(qry_str) &&
|
||||
!email.includes(qry_str) &&
|
||||
!notes.includes(qry_str) &&
|
||||
!qry_string.includes(qry_str)
|
||||
)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.toArray();
|
||||
|
||||
local_results.sort((a, b) => {
|
||||
switch (params.sort) {
|
||||
case 'name_asc':
|
||||
return (a.event_badge_full_name ?? '').localeCompare(
|
||||
b.event_badge_full_name ?? ''
|
||||
);
|
||||
case 'name_desc':
|
||||
return (b.event_badge_full_name ?? '').localeCompare(
|
||||
a.event_badge_full_name ?? ''
|
||||
);
|
||||
case 'created_asc':
|
||||
return (
|
||||
new Date(a.created_on || 0).getTime() -
|
||||
new Date(b.created_on || 0).getTime()
|
||||
);
|
||||
case 'created_desc':
|
||||
return (
|
||||
new Date(b.created_on || 0).getTime() -
|
||||
new Date(a.created_on || 0).getTime()
|
||||
);
|
||||
default:
|
||||
return (
|
||||
new Date(b.created_on || 0).getTime() -
|
||||
new Date(a.created_on || 0).getTime()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const local_ids = local_results
|
||||
.map((e) => String(e.id || e.event_exhibit_tracking_id))
|
||||
.filter(Boolean);
|
||||
|
||||
if (current_search_id === last_search_id) {
|
||||
const api_ids = results
|
||||
.map((e: any) =>
|
||||
String(e.id || e.event_exhibit_tracking_id)
|
||||
)
|
||||
.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}: Local path found ${local_ids.length} items.`
|
||||
);
|
||||
untrack(() => {
|
||||
tracking_id_li = api_ids;
|
||||
$events_sess.leads.submit_status__search = 'done';
|
||||
$events_sess.leads.last_refresh_time = new Date().toISOString();
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (current_search_id === last_search_id) {
|
||||
console.error('Lead revalidation failed:', error);
|
||||
untrack(() => {
|
||||
$events_sess.leads.submit_status__search = 'error';
|
||||
tracking_id_li = local_ids;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Exhibit Tracking Local Search failed.', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handle_export() {
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
if (!exhibit_id) return;
|
||||
// 2. REVALIDATE: API Request
|
||||
try {
|
||||
let order_by_li: any = {};
|
||||
switch (params.sort) {
|
||||
case 'name_asc':
|
||||
order_by_li = { event_badge_full_name: 'ASC' };
|
||||
break;
|
||||
case 'name_desc':
|
||||
order_by_li = { event_badge_full_name: 'DESC' };
|
||||
break;
|
||||
case 'created_asc':
|
||||
order_by_li = { created_on: 'ASC' };
|
||||
break;
|
||||
case 'created_desc':
|
||||
order_by_li = { created_on: 'DESC' };
|
||||
break;
|
||||
default:
|
||||
order_by_li = { created_on: 'DESC' };
|
||||
}
|
||||
|
||||
await events_func.download_export__event_exhibit_tracking({
|
||||
const q_event_id: string = page.params.event_id ?? '';
|
||||
const q_exhibit_id: string = exhibit_id ?? '';
|
||||
const q_licensee_email: string | null =
|
||||
params.licensee_email !== 'all'
|
||||
? (params.licensee_email ?? '')
|
||||
: null;
|
||||
|
||||
const results = await events_func.search__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: exhibit_id,
|
||||
log_lvl: 1
|
||||
event_id: q_event_id,
|
||||
event_exhibit_id: q_exhibit_id,
|
||||
fulltext_search_qry_str: qry_str || null,
|
||||
qry_external_person_id: q_licensee_email,
|
||||
hidden: params.show_hidden ? 'all' : 'not_hidden',
|
||||
order_by_li,
|
||||
limit: 150
|
||||
});
|
||||
}
|
||||
|
||||
function toggle_main_tab() {
|
||||
if (active_tab === 'add') {
|
||||
set_active_tab('list');
|
||||
previous_main_tab = 'list';
|
||||
} else {
|
||||
set_active_tab('add');
|
||||
previous_main_tab = 'add';
|
||||
if (current_search_id === last_search_id) {
|
||||
const api_ids = results
|
||||
.map((e: any) => String(e.id || e.event_exhibit_tracking_id))
|
||||
.filter(Boolean);
|
||||
|
||||
if (log_lvl)
|
||||
console.log(
|
||||
`📦 [Trace] Lead Search #${current_search_id}: API revalidation found ${api_ids.length} items.`
|
||||
);
|
||||
|
||||
untrack(() => {
|
||||
tracking_id_li = api_ids;
|
||||
$events_sess.leads.submit_status__search = 'done';
|
||||
$events_sess.leads.last_refresh_time = new Date().toISOString();
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (current_search_id === last_search_id) {
|
||||
console.error('Lead revalidation failed:', error);
|
||||
untrack(() => {
|
||||
$events_sess.leads.submit_status__search = 'error';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggle_manage_tab() {
|
||||
if (active_tab === 'manage') {
|
||||
set_active_tab(previous_main_tab);
|
||||
} else {
|
||||
set_active_tab('manage');
|
||||
}
|
||||
async function handle_export() {
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
if (!exhibit_id) return;
|
||||
|
||||
await events_func.download_export__event_exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: exhibit_id,
|
||||
log_lvl: 1
|
||||
});
|
||||
}
|
||||
|
||||
function toggle_main_tab() {
|
||||
if (active_tab === 'add') {
|
||||
set_active_tab('list');
|
||||
previous_main_tab = 'list';
|
||||
} else {
|
||||
set_active_tab('add');
|
||||
previous_main_tab = 'add';
|
||||
}
|
||||
}
|
||||
|
||||
function toggle_manage_tab() {
|
||||
if (active_tab === 'manage') {
|
||||
set_active_tab(previous_main_tab);
|
||||
} else {
|
||||
set_active_tab('manage');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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 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">
|
||||
<div class="flex flex-col min-w-0">
|
||||
<h1 class="text-base sm:text-lg font-bold truncate leading-tight">
|
||||
<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-2 shadow-sm">
|
||||
<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'}
|
||||
</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 class="flex items-center gap-1 sm:gap-2">
|
||||
@@ -404,9 +429,8 @@
|
||||
<!-- Add Lead / Lead List Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-filled-primary font-bold shadow-sm px-2 sm:px-4"
|
||||
onclick={toggle_main_tab}
|
||||
>
|
||||
class="btn btn-sm preset-filled-primary px-2 font-bold shadow-sm sm:px-4"
|
||||
onclick={toggle_main_tab}>
|
||||
{#if active_tab === 'add'}
|
||||
<ListIcon size="1.25em" class="sm:mr-2" />
|
||||
<span class="hidden sm:inline">Lead List</span>
|
||||
@@ -420,12 +444,11 @@
|
||||
{#if $ae_loc.show_leads_payment}
|
||||
<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-outlined-success={active_tab !== 'payment'}
|
||||
onclick={() => set_active_tab('payment')}
|
||||
title="Payment & Upgrades"
|
||||
>
|
||||
title="Payment & Upgrades">
|
||||
<CreditCard size="1.25em" />
|
||||
</button>
|
||||
{/if}
|
||||
@@ -433,67 +456,65 @@
|
||||
<!-- Manage / Config -->
|
||||
<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-outlined-surface={active_tab !== 'manage'}
|
||||
onclick={toggle_manage_tab}
|
||||
title="Manage Exhibit"
|
||||
>
|
||||
title="Manage Exhibit">
|
||||
<Settings size="1.25em" />
|
||||
</button>
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content Area - Stable Width -->
|
||||
<div class="w-full flex-1 flex flex-col items-center">
|
||||
<div class="w-full px-4 sm:px-6 py-6 space-y-6">
|
||||
<div class="flex w-full flex-1 flex-col items-center">
|
||||
<div class="w-full space-y-6 px-4 py-6 sm:px-6">
|
||||
{#if !is_signed_in}
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<div class="mx-auto w-full max-w-4xl">
|
||||
<Tab_start />
|
||||
</div>
|
||||
{:else if active_tab === 'add'}
|
||||
<Tab_add exhibit_id={page.params.exhibit_id ?? ''} />
|
||||
{: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 />
|
||||
</div>
|
||||
{:else if active_tab === 'list'}
|
||||
<div class="w-full flex flex-col space-y-6">
|
||||
<div class="flex justify-between items-center px-2">
|
||||
<h2 class="text-xl sm:text-2xl font-bold">Lead List</h2>
|
||||
<div class="flex w-full flex-col space-y-6">
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<h2 class="text-xl font-bold sm:text-2xl">Lead List</h2>
|
||||
{#if $lq__exhibit_obj?.leads_api_access === true}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-outlined-secondary"
|
||||
onclick={handle_export}
|
||||
>
|
||||
onclick={handle_export}>
|
||||
<Download size="1.2em" class="mr-2" /> Export
|
||||
</button>
|
||||
{/if}
|
||||
</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}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-10 opacity-50 text-center w-full"
|
||||
>
|
||||
<LoaderCircle size="3em" class="animate-spin mb-4 mx-auto" />
|
||||
class="flex w-full flex-col items-center justify-center p-10 text-center opacity-50">
|
||||
<LoaderCircle
|
||||
size="3em"
|
||||
class="mx-auto mb-4 animate-spin" />
|
||||
<p class="text-xl">Searching leads...</p>
|
||||
</div>
|
||||
{: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}
|
||||
</div>
|
||||
{: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 />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
@@ -1,138 +1,186 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_custom_questions.svelte
|
||||
* Exhibitor Custom Questions Editor - Handles leads_custom_questions_json.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import { ae_api } from '$lib/stores/ae_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { List, LoaderCircle, MessageSquare, Plus, Save, Trash2 } from '@lucide/svelte';
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
event_id: string;
|
||||
custom_questions_json?: string;
|
||||
}
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_custom_questions.svelte
|
||||
* Exhibitor Custom Questions Editor - Handles leads_custom_questions_json.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import { ae_api } from '$lib/stores/ae_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import {
|
||||
List,
|
||||
LoaderCircle,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2
|
||||
} from '@lucide/svelte';
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
event_id: string;
|
||||
custom_questions_json?: string;
|
||||
}
|
||||
|
||||
let { exhibit_id, event_id, custom_questions_json = '[]' }: Props = $props();
|
||||
let { exhibit_id, event_id, custom_questions_json = '[]' }: Props = $props();
|
||||
|
||||
let questions: any[] = $state([]);
|
||||
let is_saving = $state(false);
|
||||
// Track the JSON as it was last saved so we can detect unsaved changes
|
||||
let saved_json = $state('[]');
|
||||
let questions: any[] = $state([]);
|
||||
let is_saving = $state(false);
|
||||
// Track the JSON as it was last saved so we can detect unsaved changes
|
||||
let saved_json = $state('[]');
|
||||
|
||||
$effect(() => {
|
||||
const incoming = custom_questions_json; // reactive dependency
|
||||
try {
|
||||
const parsed = JSON.parse(incoming || '[]');
|
||||
untrack(() => {
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
// Incoming prop has real content — load it (initial load or external update)
|
||||
questions = parsed;
|
||||
saved_json = JSON.stringify(parsed);
|
||||
} else if (questions.length === 0) {
|
||||
// Both empty — initialize state cleanly
|
||||
saved_json = '[]';
|
||||
}
|
||||
// If parsed is empty but we already have questions: the API response
|
||||
// stripped leads_custom_questions_json from its return object and
|
||||
// overwrote Dexie with null. Keep our in-memory questions intact.
|
||||
});
|
||||
} catch (e) {
|
||||
untrack(() => { if (questions.length === 0) { questions = []; saved_json = '[]'; } });
|
||||
}
|
||||
});
|
||||
|
||||
// True whenever the current questions differ from the last saved state
|
||||
let is_dirty = $derived(JSON.stringify(questions) !== saved_json);
|
||||
|
||||
async function save_questions() {
|
||||
if (!exhibit_id) return;
|
||||
is_saving = true;
|
||||
try {
|
||||
await events_func.update_ae_obj__exhibit({
|
||||
api_cfg: $ae_api,
|
||||
event_id: event_id,
|
||||
exhibit_id: exhibit_id,
|
||||
data_kv: {
|
||||
leads_custom_questions_json: JSON.stringify(questions)
|
||||
}
|
||||
});
|
||||
saved_json = JSON.stringify(questions);
|
||||
} finally {
|
||||
is_saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function add_question() {
|
||||
questions.push({
|
||||
// code: machine key used as the responses_json property name
|
||||
// question: human-readable label shown to the exhibitor/scanner
|
||||
// option_li: array of choices; first element is always '' (blank/no-selection default)
|
||||
code: '',
|
||||
question: '',
|
||||
type: 'text',
|
||||
option_li: ['']
|
||||
$effect(() => {
|
||||
const incoming = custom_questions_json; // reactive dependency
|
||||
try {
|
||||
const parsed = JSON.parse(incoming || '[]');
|
||||
untrack(() => {
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
// Incoming prop has real content — load it (initial load or external update)
|
||||
questions = parsed;
|
||||
saved_json = JSON.stringify(parsed);
|
||||
} else if (questions.length === 0) {
|
||||
// Both empty — initialize state cleanly
|
||||
saved_json = '[]';
|
||||
}
|
||||
// If parsed is empty but we already have questions: the API response
|
||||
// stripped leads_custom_questions_json from its return object and
|
||||
// overwrote Dexie with null. Keep our in-memory questions intact.
|
||||
});
|
||||
} catch (e) {
|
||||
untrack(() => {
|
||||
if (questions.length === 0) {
|
||||
questions = [];
|
||||
saved_json = '[]';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function remove_question(index: number) {
|
||||
questions.splice(index, 1);
|
||||
}
|
||||
// True whenever the current questions differ from the last saved state
|
||||
let is_dirty = $derived(JSON.stringify(questions) !== saved_json);
|
||||
|
||||
// Helpers for option_li ↔ comma-string conversion in the UI
|
||||
function get_options_str(q: any): string {
|
||||
const li: string[] = Array.isArray(q.option_li) ? q.option_li : [];
|
||||
return li.filter((o: string) => o !== '').join(', ');
|
||||
async function save_questions() {
|
||||
if (!exhibit_id) return;
|
||||
is_saving = true;
|
||||
try {
|
||||
await events_func.update_ae_obj__exhibit({
|
||||
api_cfg: $ae_api,
|
||||
event_id: event_id,
|
||||
exhibit_id: exhibit_id,
|
||||
data_kv: {
|
||||
leads_custom_questions_json: JSON.stringify(questions)
|
||||
}
|
||||
});
|
||||
saved_json = JSON.stringify(questions);
|
||||
} finally {
|
||||
is_saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function set_options_str(q: any, val: string) {
|
||||
// Always prepend empty string so the select has a blank default option
|
||||
q.option_li = ['', ...val.split(',').map((s: string) => s.trim()).filter(Boolean)];
|
||||
}
|
||||
function add_question() {
|
||||
questions.push({
|
||||
// code: machine key used as the responses_json property name
|
||||
// question: human-readable label shown to the exhibitor/scanner
|
||||
// option_li: array of choices; first element is always '' (blank/no-selection default)
|
||||
code: '',
|
||||
question: '',
|
||||
type: 'text',
|
||||
option_li: ['']
|
||||
});
|
||||
}
|
||||
|
||||
function remove_question(index: number) {
|
||||
questions.splice(index, 1);
|
||||
}
|
||||
|
||||
// Helpers for option_li ↔ comma-string conversion in the UI
|
||||
function get_options_str(q: any): string {
|
||||
const li: string[] = Array.isArray(q.option_li) ? q.option_li : [];
|
||||
return li.filter((o: string) => o !== '').join(', ');
|
||||
}
|
||||
|
||||
function set_options_str(q: any, val: string) {
|
||||
// Always prepend empty string so the select has a blank default option
|
||||
q.option_li = [
|
||||
'',
|
||||
...val
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean)
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="custom-questions-editor space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-bold uppercase tracking-widest opacity-50">Lead Qualifiers</h3>
|
||||
<span class="text-xs opacity-40 italic">Define questions for lead capture</span>
|
||||
<h3 class="text-sm font-bold tracking-widest uppercase opacity-50">
|
||||
Lead Qualifiers
|
||||
</h3>
|
||||
<span class="text-xs italic opacity-40"
|
||||
>Define questions for lead capture</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#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) -->
|
||||
<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
|
||||
class="btn btn-sm preset-outlined-error px-2 py-1"
|
||||
onclick={() => remove_question(i)}
|
||||
title="Remove question"
|
||||
>
|
||||
title="Remove question">
|
||||
<Trash2 size="1em" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<!-- Question / display label -->
|
||||
<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">
|
||||
<MessageSquare size="1em" class="opacity-30 flex-none" />
|
||||
<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" />
|
||||
<MessageSquare
|
||||
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>
|
||||
|
||||
<!-- Code / machine key -->
|
||||
<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>
|
||||
<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" />
|
||||
<label
|
||||
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>
|
||||
|
||||
<!-- Response Type -->
|
||||
<div class="space-y-1">
|
||||
<label class="text-[10px] uppercase font-bold 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 text-xs p-1 rounded w-full">
|
||||
<label
|
||||
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="textarea">Long Text</option>
|
||||
<option value="toggle">Yes / No (Toggle)</option>
|
||||
@@ -141,18 +189,24 @@
|
||||
</div>
|
||||
|
||||
{#if q.type === 'option'}
|
||||
<div class="space-y-1 pt-2 border-t border-surface-500/10">
|
||||
<label class="text-[10px] uppercase font-bold opacity-40" for="custom-q-{i}-options">Options (comma-separated)</label>
|
||||
<div class="border-surface-500/10 space-y-1 border-t pt-2">
|
||||
<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">
|
||||
<List size="1em" class="opacity-30 flex-none" />
|
||||
<List size="1em" class="flex-none opacity-30" />
|
||||
<input
|
||||
id="custom-q-{i}-options"
|
||||
type="text"
|
||||
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"
|
||||
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>
|
||||
{/if}
|
||||
@@ -160,7 +214,8 @@
|
||||
{/each}
|
||||
|
||||
{#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" />
|
||||
<p class="text-sm italic">No custom questions defined yet.</p>
|
||||
</div>
|
||||
@@ -169,11 +224,15 @@
|
||||
|
||||
<!-- Unsaved changes warning -->
|
||||
{#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}
|
||||
|
||||
<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
|
||||
</button>
|
||||
<button
|
||||
@@ -181,10 +240,9 @@
|
||||
class:preset-filled-primary={is_dirty}
|
||||
class:preset-outlined-surface={!is_dirty}
|
||||
onclick={save_questions}
|
||||
disabled={is_saving || !is_dirty}
|
||||
>
|
||||
disabled={is_saving || !is_dirty}>
|
||||
{#if is_saving}
|
||||
<LoaderCircle size="1.2em" class="animate-spin mr-2" />
|
||||
<LoaderCircle size="1.2em" class="mr-2 animate-spin" />
|
||||
{:else}
|
||||
<Save size="1.2em" class="mr-2" />
|
||||
{/if}
|
||||
|
||||
@@ -1,148 +1,164 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_license_list.svelte
|
||||
* Exhibitor License Management - Handles parsing and editing event_exhibit.license_li_json
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import { ae_api } from '$lib/stores/ae_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { Key, LoaderCircle, Mail, Plus, Save, Trash2, User, Users } from '@lucide/svelte';
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
event_id: string;
|
||||
license_li_json?: string; // Raw JSON string from DB
|
||||
license_max?: number;
|
||||
}
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_license_list.svelte
|
||||
* Exhibitor License Management - Handles parsing and editing event_exhibit.license_li_json
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import { ae_api } from '$lib/stores/ae_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import {
|
||||
Key,
|
||||
LoaderCircle,
|
||||
Mail,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
User,
|
||||
Users
|
||||
} from '@lucide/svelte';
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
event_id: string;
|
||||
license_li_json?: string; // Raw JSON string from DB
|
||||
license_max?: number;
|
||||
}
|
||||
|
||||
let { exhibit_id, event_id, license_li_json = '[]', license_max = 0 }: Props = $props();
|
||||
let {
|
||||
exhibit_id,
|
||||
event_id,
|
||||
license_li_json = '[]',
|
||||
license_max = 0
|
||||
}: Props = $props();
|
||||
|
||||
// Local state for the parsed list
|
||||
let local_license_li: any[] = $state([]);
|
||||
let is_saving = $state(false);
|
||||
// Local state for the parsed list
|
||||
let local_license_li: any[] = $state([]);
|
||||
let is_saving = $state(false);
|
||||
|
||||
// Parse JSON into local state
|
||||
$effect(() => {
|
||||
try {
|
||||
const raw = license_li_json;
|
||||
if (!raw) {
|
||||
untrack(() => local_license_li = []);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle both string and pre-parsed array/object formats
|
||||
let parsed = [];
|
||||
if (Array.isArray(raw)) {
|
||||
parsed = raw;
|
||||
} else if (typeof raw === 'string') {
|
||||
parsed = JSON.parse(raw || '[]');
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
local_license_li = Array.isArray(parsed) ? parsed : [];
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to parse license_li_json', e);
|
||||
untrack(() => {
|
||||
local_license_li = [];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function save_licenses() {
|
||||
if (!exhibit_id) return;
|
||||
is_saving = true;
|
||||
try {
|
||||
const json_str = JSON.stringify(local_license_li);
|
||||
await events_func.update_ae_obj__exhibit({
|
||||
api_cfg: $ae_api,
|
||||
event_id: event_id,
|
||||
exhibit_id: exhibit_id,
|
||||
data_kv: {
|
||||
license_li_json: json_str
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to save licenses', e);
|
||||
} finally {
|
||||
is_saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function add_license() {
|
||||
if (local_license_li.length >= (license_max || 1)) {
|
||||
alert(`Maximum licenses (${license_max}) reached.`);
|
||||
// Parse JSON into local state
|
||||
$effect(() => {
|
||||
try {
|
||||
const raw = license_li_json;
|
||||
if (!raw) {
|
||||
untrack(() => (local_license_li = []));
|
||||
return;
|
||||
}
|
||||
local_license_li.push({
|
||||
full_name: '',
|
||||
email: '',
|
||||
passcode: Math.random().toString(36).substring(2, 8).toUpperCase()
|
||||
|
||||
// Handle both string and pre-parsed array/object formats
|
||||
let parsed = [];
|
||||
if (Array.isArray(raw)) {
|
||||
parsed = raw;
|
||||
} else if (typeof raw === 'string') {
|
||||
parsed = JSON.parse(raw || '[]');
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
local_license_li = Array.isArray(parsed) ? parsed : [];
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to parse license_li_json', e);
|
||||
untrack(() => {
|
||||
local_license_li = [];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function remove_license(index: number) {
|
||||
if (confirm('Remove this license? The user will lose access immediately.')) {
|
||||
local_license_li.splice(index, 1);
|
||||
}
|
||||
async function save_licenses() {
|
||||
if (!exhibit_id) return;
|
||||
is_saving = true;
|
||||
try {
|
||||
const json_str = JSON.stringify(local_license_li);
|
||||
await events_func.update_ae_obj__exhibit({
|
||||
api_cfg: $ae_api,
|
||||
event_id: event_id,
|
||||
exhibit_id: exhibit_id,
|
||||
data_kv: {
|
||||
license_li_json: json_str
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to save licenses', e);
|
||||
} finally {
|
||||
is_saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function add_license() {
|
||||
if (local_license_li.length >= (license_max || 1)) {
|
||||
alert(`Maximum licenses (${license_max}) reached.`);
|
||||
return;
|
||||
}
|
||||
local_license_li.push({
|
||||
full_name: '',
|
||||
email: '',
|
||||
passcode: Math.random().toString(36).substring(2, 8).toUpperCase()
|
||||
});
|
||||
}
|
||||
|
||||
function remove_license(index: number) {
|
||||
if (
|
||||
confirm('Remove this license? The user will lose access immediately.')
|
||||
) {
|
||||
local_license_li.splice(index, 1);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="exhibit-license-list space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-bold uppercase tracking-widest opacity-50">Assigned Licenses</h3>
|
||||
<span class="text-xs font-mono bg-surface-500/10 px-2 py-1 rounded">
|
||||
<h3 class="text-sm font-bold tracking-widest uppercase opacity-50">
|
||||
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}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
{#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">
|
||||
<button
|
||||
class="absolute top-2 right-2 p-2 text-error-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
<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
|
||||
class="text-error-500 absolute top-2 right-2 p-2 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onclick={() => remove_license(i)}
|
||||
title="Remove License"
|
||||
>
|
||||
title="Remove License">
|
||||
<Trash2 size="1.2em" />
|
||||
</button>
|
||||
|
||||
<!-- Name -->
|
||||
<div class="flex items-center gap-3">
|
||||
<User size="1.2em" class="opacity-30" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={license.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"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={license.full_name}
|
||||
placeholder="Full Name"
|
||||
class="border-surface-500/20 focus:border-primary-500 flex-1 border-b bg-transparent text-sm font-bold outline-none" />
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="flex items-center gap-3">
|
||||
<Mail size="1.2em" class="opacity-30" />
|
||||
<input
|
||||
type="email"
|
||||
bind:value={license.email}
|
||||
placeholder="email@example.com"
|
||||
class="bg-transparent border-b border-surface-500/20 focus:border-primary-500 outline-none flex-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
bind:value={license.email}
|
||||
placeholder="email@example.com"
|
||||
class="border-surface-500/20 focus:border-primary-500 flex-1 border-b bg-transparent text-sm outline-none" />
|
||||
</div>
|
||||
|
||||
<!-- Passcode -->
|
||||
<div class="flex items-center gap-3">
|
||||
<Key size="1.2em" class="opacity-30" />
|
||||
<input
|
||||
type="text"
|
||||
bind:value={license.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"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={license.passcode}
|
||||
placeholder="PASSCODE"
|
||||
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>
|
||||
{/each}
|
||||
|
||||
{#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" />
|
||||
<p class="text-sm italic">No licenses assigned yet.</p>
|
||||
</div>
|
||||
@@ -150,25 +166,23 @@
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 pt-2">
|
||||
<button
|
||||
class="btn btn-sm preset-filled-secondary flex-1"
|
||||
<button
|
||||
class="btn btn-sm preset-filled-secondary flex-1"
|
||||
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
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-sm preset-filled-primary flex-1"
|
||||
<button
|
||||
class="btn btn-sm preset-filled-primary flex-1"
|
||||
onclick={save_licenses}
|
||||
disabled={is_saving}
|
||||
>
|
||||
disabled={is_saving}>
|
||||
{#if is_saving}
|
||||
<LoaderCircle size="1.2em" class="animate-spin mr-2" />
|
||||
<LoaderCircle size="1.2em" class="mr-2 animate-spin" />
|
||||
{:else}
|
||||
<Save size="1.2em" class="mr-2" />
|
||||
{/if}
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_payment.svelte
|
||||
* Leads Payment Stub.
|
||||
*/
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_payment.svelte
|
||||
* Leads Payment Stub.
|
||||
*/
|
||||
</script>
|
||||
|
||||
<div class="exhibit-payment p-4 card">
|
||||
<div class="exhibit-payment card p-4">
|
||||
<h3 class="h3">Payment & Licensing</h3>
|
||||
<p>Placeholder for Stripe integration.</p>
|
||||
</div>
|
||||
|
||||
@@ -1,218 +1,255 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_signin.svelte
|
||||
* Exhibitor Sign-In Component - Handles both Shared Passcode and Licensed User login.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_loc } from '$lib/stores/ae_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 { untrack } from 'svelte';
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_signin.svelte
|
||||
* Exhibitor Sign-In Component - Handles both Shared Passcode and Licensed User login.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_loc } from '$lib/stores/ae_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 { untrack } from 'svelte';
|
||||
|
||||
const exhibit_id = $derived(page.params.exhibit_id ?? '');
|
||||
const exhibit_id = $derived(page.params.exhibit_id ?? '');
|
||||
|
||||
let lq__exhibit_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!exhibit_id) return null;
|
||||
return await db_events.exhibit.get(exhibit_id);
|
||||
})
|
||||
);
|
||||
let lq__exhibit_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!exhibit_id) return null;
|
||||
return await db_events.exhibit.get(exhibit_id);
|
||||
})
|
||||
);
|
||||
|
||||
// Form State
|
||||
let signin_mode = $state('passcode'); // 'passcode' or 'licensed'
|
||||
let passcode = $state('');
|
||||
let email = $state('');
|
||||
let user_passcode = $state('');
|
||||
let status = $state('idle'); // 'idle', 'submitting', 'error', 'success'
|
||||
let error_msg = $state('');
|
||||
// Form State
|
||||
let signin_mode = $state('passcode'); // 'passcode' or 'licensed'
|
||||
let passcode = $state('');
|
||||
let email = $state('');
|
||||
let user_passcode = $state('');
|
||||
let status = $state('idle'); // 'idle', 'submitting', 'error', 'success'
|
||||
let error_msg = $state('');
|
||||
|
||||
// --- Auto-prefill for Trusted Users ---
|
||||
$effect(() => {
|
||||
if ($ae_loc.trusted_access && $lq__exhibit_obj?.staff_passcode) {
|
||||
untrack(() => {
|
||||
if (!passcode) passcode = $lq__exhibit_obj?.staff_passcode ?? '';
|
||||
});
|
||||
// --- Auto-prefill for Trusted Users ---
|
||||
$effect(() => {
|
||||
if ($ae_loc.trusted_access && $lq__exhibit_obj?.staff_passcode) {
|
||||
untrack(() => {
|
||||
if (!passcode) passcode = $lq__exhibit_obj?.staff_passcode ?? '';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function handle_signin() {
|
||||
if (!$lq__exhibit_obj) return;
|
||||
status = 'submitting';
|
||||
error_msg = '';
|
||||
|
||||
// Delay for better UX
|
||||
await new Promise((r) => setTimeout(r, 800));
|
||||
|
||||
if (signin_mode === 'passcode') {
|
||||
// 1. Shared Passcode logic
|
||||
if (passcode === $lq__exhibit_obj.staff_passcode) {
|
||||
// SUCCESS
|
||||
complete_signin($lq__exhibit_obj.staff_passcode, 'shared');
|
||||
} else {
|
||||
status = 'error';
|
||||
error_msg =
|
||||
'Invalid shared passcode. Please check with your booth manager.';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 2. Licensed User logic
|
||||
try {
|
||||
// Determine raw JSON string
|
||||
const raw_json = $lq__exhibit_obj?.license_li_json;
|
||||
|
||||
async function handle_signin() {
|
||||
if (!$lq__exhibit_obj) return;
|
||||
status = 'submitting';
|
||||
error_msg = '';
|
||||
// Parse if string, otherwise use empty array
|
||||
const licenses =
|
||||
typeof raw_json === 'string'
|
||||
? JSON.parse(raw_json || '[]')
|
||||
: Array.isArray(raw_json)
|
||||
? raw_json
|
||||
: [];
|
||||
|
||||
// Delay for better UX
|
||||
await new Promise(r => setTimeout(r, 800));
|
||||
const found = licenses.find(
|
||||
(l: any) =>
|
||||
l.email?.toLowerCase() === email.toLowerCase().trim()
|
||||
);
|
||||
|
||||
if (signin_mode === 'passcode') {
|
||||
// 1. Shared Passcode logic
|
||||
if (passcode === $lq__exhibit_obj.staff_passcode) {
|
||||
if (found && found.passcode === user_passcode) {
|
||||
// SUCCESS
|
||||
complete_signin($lq__exhibit_obj.staff_passcode, 'shared');
|
||||
complete_signin(found.email, 'licensed');
|
||||
} else {
|
||||
status = 'error';
|
||||
error_msg = 'Invalid shared passcode. Please check with your booth manager.';
|
||||
}
|
||||
} else {
|
||||
// 2. Licensed User logic
|
||||
try {
|
||||
// Determine raw JSON string
|
||||
const raw_json = $lq__exhibit_obj?.license_li_json;
|
||||
|
||||
// Parse if string, otherwise use empty array
|
||||
const licenses = typeof raw_json === 'string' ? JSON.parse(raw_json || '[]') : (Array.isArray(raw_json) ? raw_json : []);
|
||||
|
||||
const found = licenses.find((l: any) => l.email?.toLowerCase() === email.toLowerCase().trim());
|
||||
|
||||
if (found && found.passcode === user_passcode) {
|
||||
// SUCCESS
|
||||
complete_signin(found.email, 'licensed');
|
||||
} else {
|
||||
status = 'error';
|
||||
error_msg = 'Invalid email or personal passcode.';
|
||||
}
|
||||
} catch (e) {
|
||||
status = 'error';
|
||||
error_msg = 'System error validating licenses.';
|
||||
error_msg = 'Invalid email or personal passcode.';
|
||||
}
|
||||
} catch (e) {
|
||||
status = 'error';
|
||||
error_msg = 'System error validating licenses.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function complete_signin(key: string, type: string) {
|
||||
status = 'success';
|
||||
function complete_signin(key: string, type: string) {
|
||||
status = 'success';
|
||||
|
||||
// Save to persistent store
|
||||
if (!$events_loc.leads.auth_exhibit_kv) $events_loc.leads.auth_exhibit_kv = {};
|
||||
// Save to persistent store
|
||||
if (!$events_loc.leads.auth_exhibit_kv)
|
||||
$events_loc.leads.auth_exhibit_kv = {};
|
||||
|
||||
$events_loc.leads.auth_exhibit_kv[exhibit_id] = {
|
||||
key: key,
|
||||
type: type,
|
||||
updated_on: new Date().toISOString()
|
||||
};
|
||||
$events_loc.leads.auth_exhibit_kv[exhibit_id] = {
|
||||
key: key,
|
||||
type: type,
|
||||
updated_on: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Also update session passcode if shared mode
|
||||
if (type === 'shared') {
|
||||
$events_sess.leads.entered_passcode = key;
|
||||
}
|
||||
|
||||
// Trigger a reload or UI update if needed
|
||||
// (The parent +page.svelte should reactively update is_signed_in)
|
||||
// Also update session passcode if shared mode
|
||||
if (type === 'shared') {
|
||||
$events_sess.leads.entered_passcode = key;
|
||||
}
|
||||
|
||||
// Trigger a reload or UI update if needed
|
||||
// (The parent +page.svelte should reactively update is_signed_in)
|
||||
}
|
||||
</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 -->
|
||||
<div class="flex p-1 bg-surface-500/10 rounded-xl">
|
||||
<div class="bg-surface-500/10 flex rounded-xl p-1">
|
||||
<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:shadow-sm={signin_mode === 'passcode'}
|
||||
class:opacity-50={signin_mode !== 'passcode'}
|
||||
onclick={() => signin_mode = 'passcode'}
|
||||
>
|
||||
onclick={() => (signin_mode = 'passcode')}>
|
||||
<Lock size="1.2em" /> Shared Code
|
||||
</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:shadow-sm={signin_mode === 'licensed'}
|
||||
class:opacity-50={signin_mode !== 'licensed'}
|
||||
onclick={() => signin_mode = 'licensed'}
|
||||
>
|
||||
onclick={() => (signin_mode = 'licensed')}>
|
||||
<User size="1.2em" /> Licensed User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 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'}
|
||||
<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">
|
||||
<span class="text-[10px] uppercase font-bold opacity-50 ml-1 tracking-widest">Booth Passcode</span>
|
||||
<div class="input-group input-group-divider grid-cols-[auto_1fr] preset-tonal-surface rounded-xl overflow-hidden border border-surface-500/20">
|
||||
<span
|
||||
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>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={passcode}
|
||||
placeholder="Enter shared code..."
|
||||
class="bg-transparent font-mono tracking-[0.3em] font-bold text-center"
|
||||
autocomplete="off"
|
||||
/>
|
||||
class="bg-transparent text-center font-mono font-bold tracking-[0.3em]"
|
||||
autocomplete="off" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{: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">
|
||||
<span class="text-[10px] uppercase font-bold opacity-50 ml-1 tracking-widest">Email Address</span>
|
||||
<div class="input-group input-group-divider grid-cols-[auto_1fr] preset-tonal-surface rounded-xl overflow-hidden border border-surface-500/20">
|
||||
<div class="input-group-shim"><Mail size="1.2em" /></div>
|
||||
<span
|
||||
class="ml-1 text-[10px] font-bold tracking-widest uppercase opacity-50"
|
||||
>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
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="your@email.com"
|
||||
class="bg-transparent"
|
||||
/>
|
||||
class="bg-transparent" />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="label">
|
||||
<span class="text-[10px] uppercase font-bold opacity-50 ml-1 tracking-widest">Personal Passcode</span>
|
||||
<div class="input-group input-group-divider grid-cols-[auto_1fr] preset-tonal-surface rounded-xl overflow-hidden border border-surface-500/20">
|
||||
<span
|
||||
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>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={user_passcode}
|
||||
placeholder="Your code..."
|
||||
class="bg-transparent font-mono font-bold"
|
||||
autocomplete="off"
|
||||
/>
|
||||
autocomplete="off" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if status === 'error'}
|
||||
<div class="p-3 rounded-lg preset-tonal-error flex items-start gap-3 animate-shake">
|
||||
<CircleAlert size="1.2em" class="shrink-0 mt-0.5" />
|
||||
<p class="text-xs font-bold leading-tight">{error_msg}</p>
|
||||
<div
|
||||
class="preset-tonal-error animate-shake flex items-start gap-3 rounded-lg p-3">
|
||||
<CircleAlert size="1.2em" class="mt-0.5 shrink-0" />
|
||||
<p class="text-xs leading-tight font-bold">{error_msg}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-lg preset-filled-primary w-full font-bold shadow-lg shadow-primary-500/20 group"
|
||||
disabled={status === 'submitting'}
|
||||
>
|
||||
class="btn btn-lg preset-filled-primary shadow-primary-500/20 group w-full font-bold shadow-lg"
|
||||
disabled={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...
|
||||
{:else if status === 'success'}
|
||||
<CircleCheck size="1.5em" class="mr-2" />
|
||||
Welcome!
|
||||
{: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}
|
||||
</button>
|
||||
</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.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
/* Shake animation for errors */
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-4px); }
|
||||
75% { transform: translateX(4px); }
|
||||
/* Shake animation for errors */
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.animate-shake {
|
||||
animation: shake 0.2s ease-in-out 0s 2;
|
||||
25% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
</style>
|
||||
75% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
.animate-shake {
|
||||
animation: shake 0.2s ease-in-out 0s 2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,74 +1,95 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
lq__event_exhibit_tracking_obj_li: any;
|
||||
log_lvl?: number;
|
||||
interface Props {
|
||||
lq__event_exhibit_tracking_obj_li: any;
|
||||
log_lvl?: number;
|
||||
}
|
||||
|
||||
let { lq__event_exhibit_tracking_obj_li, log_lvl = 0 }: Props = $props();
|
||||
|
||||
import {
|
||||
ChevronRight,
|
||||
Clock,
|
||||
FileText,
|
||||
LoaderCircle,
|
||||
Mail,
|
||||
MapPin,
|
||||
User
|
||||
} from '@lucide/svelte';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import { page } from '$app/state';
|
||||
|
||||
// Full ISO datetime for tooltip (hover title)
|
||||
function format_date_full(date_str: string) {
|
||||
if (!date_str) return '';
|
||||
return new Date(date_str).toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Fuzzy relative time — "~10 min ago", "~1.5 hrs ago", "~2 days ago"
|
||||
// Exact for first few minutes, increasingly coarse after that.
|
||||
function fuzzy_time_ago(date_str: string) {
|
||||
if (!date_str) return '';
|
||||
const diff_ms = Date.now() - new Date(date_str).getTime();
|
||||
if (diff_ms < 0) return 'just now';
|
||||
|
||||
const min = diff_ms / 60000;
|
||||
const hr = diff_ms / 3600000;
|
||||
const days = Math.floor(hr / 24);
|
||||
|
||||
if (min < 0.75) return 'just now';
|
||||
if (min < 2) return '~1 min ago';
|
||||
if (min < 7) return `~${Math.round(min)} min ago`; // e.g. "~3 min ago"
|
||||
if (min < 20) return `~${Math.round(min / 5) * 5} min ago`; // rounds to 5 min
|
||||
if (min < 55) return `~${Math.round(min / 15) * 15} min ago`; // rounds to 15 min
|
||||
if (hr < 1.5) return '~1 hr ago';
|
||||
if (hr < 23.5) {
|
||||
const r = Math.round(hr * 2) / 2; // rounds to nearest 0.5 hr
|
||||
return `~${r} hrs ago`;
|
||||
}
|
||||
|
||||
let { lq__event_exhibit_tracking_obj_li, log_lvl = 0 }: Props = $props();
|
||||
|
||||
import { ChevronRight, Clock, FileText, LoaderCircle, Mail, MapPin, User } from '@lucide/svelte';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import { page } from '$app/state';
|
||||
|
||||
// Full ISO datetime for tooltip (hover title)
|
||||
function format_date_full(date_str: string) {
|
||||
if (!date_str) return '';
|
||||
return new Date(date_str).toLocaleString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
});
|
||||
if (days < 7) {
|
||||
const rem_hr = Math.round(hr - days * 24);
|
||||
const day_label = `~${days} day${days > 1 ? 's' : ''}`;
|
||||
return rem_hr > 1
|
||||
? `${day_label} ${rem_hr} hrs ago`
|
||||
: `${day_label} ago`;
|
||||
}
|
||||
|
||||
// Fuzzy relative time — "~10 min ago", "~1.5 hrs ago", "~2 days ago"
|
||||
// Exact for first few minutes, increasingly coarse after that.
|
||||
function fuzzy_time_ago(date_str: string) {
|
||||
if (!date_str) return '';
|
||||
const diff_ms = Date.now() - new Date(date_str).getTime();
|
||||
if (diff_ms < 0) return 'just now';
|
||||
|
||||
const min = diff_ms / 60000;
|
||||
const hr = diff_ms / 3600000;
|
||||
const days = Math.floor(hr / 24);
|
||||
|
||||
if (min < 0.75) return 'just now';
|
||||
if (min < 2) return '~1 min ago';
|
||||
if (min < 7) return `~${Math.round(min)} min ago`; // e.g. "~3 min ago"
|
||||
if (min < 20) return `~${Math.round(min / 5) * 5} min ago`; // rounds to 5 min
|
||||
if (min < 55) return `~${Math.round(min / 15) * 15} min ago`; // rounds to 15 min
|
||||
if (hr < 1.5) return '~1 hr ago';
|
||||
if (hr < 23.5) {
|
||||
const r = Math.round(hr * 2) / 2; // rounds to nearest 0.5 hr
|
||||
return `~${r} hrs ago`;
|
||||
}
|
||||
if (days < 7) {
|
||||
const rem_hr = Math.round(hr - days * 24);
|
||||
const day_label = `~${days} day${days > 1 ? 's' : ''}`;
|
||||
return rem_hr > 1 ? `${day_label} ${rem_hr} hrs ago` : `${day_label} ago`;
|
||||
}
|
||||
const weeks = Math.round(days / 7);
|
||||
if (days < 28) { return `~${weeks} week${weeks > 1 ? 's' : ''} ago`; }
|
||||
const months = Math.round(days / 30);
|
||||
if (days < 365) { return `~${months} month${months > 1 ? 's' : ''} ago`; }
|
||||
const years = Math.round(days / 365);
|
||||
return `~${years} year${years > 1 ? 's' : ''} ago`;
|
||||
const weeks = Math.round(days / 7);
|
||||
if (days < 28) {
|
||||
return `~${weeks} week${weeks > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
const months = Math.round(days / 30);
|
||||
if (days < 365) {
|
||||
return `~${months} month${months > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
const years = Math.round(days / 365);
|
||||
return `~${years} year${years > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ae_comp__exhibit_tracking_obj_li w-full px-2 sm:px-4">
|
||||
{#if !lq__event_exhibit_tracking_obj_li}
|
||||
<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>
|
||||
{: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-sm opacity-50 mt-2">
|
||||
<p class="mt-2 text-sm opacity-50">
|
||||
Start scanning badges to collect leads!
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<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">
|
||||
{lq__event_exhibit_tracking_obj_li.length} Leads Collected
|
||||
</span>
|
||||
@@ -78,8 +99,7 @@
|
||||
{#each lq__event_exhibit_tracking_obj_li as event_tracking_obj (event_tracking_obj.event_exhibit_tracking_id)}
|
||||
<a
|
||||
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 items-center gap-2">
|
||||
<User size="1.25em" class="text-primary-500" />
|
||||
@@ -91,8 +111,7 @@
|
||||
</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}
|
||||
<div class="flex items-center gap-1">
|
||||
<Mail size="1em" />
|
||||
@@ -107,27 +126,31 @@
|
||||
{/if}
|
||||
<div
|
||||
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" />
|
||||
{fuzzy_time_ago(event_tracking_obj.created_on)}
|
||||
{fuzzy_time_ago(
|
||||
event_tracking_obj.created_on
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if event_tracking_obj.exhibitor_notes}
|
||||
<div
|
||||
class="mt-2 p-2 bg-surface-100-900 rounded text-sm italic border-l-2 border-surface-300-700"
|
||||
>
|
||||
<FileText size="1em" class="inline mr-1" />
|
||||
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" />
|
||||
{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
|
||||
})}
|
||||
</div>
|
||||
{/if}
|
||||
</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" />
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -1,96 +1,106 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
log_lvl?: number;
|
||||
}
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
log_lvl?: number;
|
||||
}
|
||||
|
||||
let { exhibit_id, log_lvl = 0 }: Props = $props();
|
||||
let { exhibit_id, log_lvl = 0 }: Props = $props();
|
||||
|
||||
// *** Import other supporting libraries
|
||||
import { Eye, EyeOff, Library, LoaderCircle, RemoveFormatting, Search } from '@lucide/svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { onMount } from 'svelte';
|
||||
// *** Import other supporting libraries
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Library,
|
||||
LoaderCircle,
|
||||
RemoveFormatting,
|
||||
Search
|
||||
} from '@lucide/svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let exhibit_obj: any = $state(null);
|
||||
let exhibit_obj: any = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
const observable = liveQuery(async () => {
|
||||
if (!exhibit_id) return null;
|
||||
// 1. Try primary lookup
|
||||
let res = await db_events.exhibit.get(exhibit_id);
|
||||
// 2. Fallback to random ID index
|
||||
if (!res) {
|
||||
res = await db_events.exhibit.where('event_exhibit_id_random').equals(exhibit_id).first();
|
||||
}
|
||||
return res;
|
||||
});
|
||||
const subscription = observable.subscribe((value) => {
|
||||
exhibit_obj = value;
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
onMount(() => {
|
||||
const observable = liveQuery(async () => {
|
||||
if (!exhibit_id) return null;
|
||||
// 1. Try primary lookup
|
||||
let res = await db_events.exhibit.get(exhibit_id);
|
||||
// 2. Fallback to random ID index
|
||||
if (!res) {
|
||||
res = await db_events.exhibit
|
||||
.where('event_exhibit_id_random')
|
||||
.equals(exhibit_id)
|
||||
.first();
|
||||
}
|
||||
return res;
|
||||
});
|
||||
const subscription = observable.subscribe((value) => {
|
||||
exhibit_obj = value;
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
|
||||
// Reactive list derived from the exhibit state (Licensed Exhibit Leads Users)
|
||||
let licensee_li = $derived.by(() => {
|
||||
try {
|
||||
const raw = exhibit_obj?.license_li_json;
|
||||
if (!raw) return [];
|
||||
|
||||
// If it's already an array, return it. If it's a string, parse it.
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (typeof raw === 'string') return JSON.parse(raw || '[]');
|
||||
|
||||
return [];
|
||||
} catch (e) {
|
||||
console.error('Failed to parse licensee_li_json', e);
|
||||
return [];
|
||||
// Reactive list derived from the exhibit state (Licensed Exhibit Leads Users)
|
||||
let licensee_li = $derived.by(() => {
|
||||
try {
|
||||
const raw = exhibit_obj?.license_li_json;
|
||||
if (!raw) return [];
|
||||
|
||||
// If it's already an array, return it. If it's a string, parse it.
|
||||
if (Array.isArray(raw)) return raw;
|
||||
if (typeof raw === 'string') return JSON.parse(raw || '[]');
|
||||
|
||||
return [];
|
||||
} catch (e) {
|
||||
console.error('Failed to parse licensee_li_json', e);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Default selection logic: Aether Admins go to "all", Licensees go to "my"
|
||||
$effect(() => {
|
||||
// Wait for object to load and check if initialized
|
||||
if (!exhibit_obj) return;
|
||||
|
||||
untrack(() => {
|
||||
if (
|
||||
$events_loc.leads.tracking__qry__licensee_email === 'all' &&
|
||||
!$ae_loc.administrator_access
|
||||
) {
|
||||
$events_loc.leads.tracking__qry__licensee_email = 'my';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Default selection logic: Aether Admins go to "all", Licensees go to "my"
|
||||
$effect(() => {
|
||||
// Wait for object to load and check if initialized
|
||||
if (!exhibit_obj) return;
|
||||
|
||||
untrack(() => {
|
||||
if ($events_loc.leads.tracking__qry__licensee_email === 'all' && !$ae_loc.administrator_access) {
|
||||
$events_loc.leads.tracking__qry__licensee_email = 'my';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function handle_search_trigger() {
|
||||
if ($events_loc.leads.tracking__search_version === undefined) {
|
||||
$events_loc.leads.tracking__search_version = 0;
|
||||
}
|
||||
$events_loc.leads.tracking__search_version++;
|
||||
function handle_search_trigger() {
|
||||
if ($events_loc.leads.tracking__search_version === undefined) {
|
||||
$events_loc.leads.tracking__search_version = 0;
|
||||
}
|
||||
$events_loc.leads.tracking__search_version++;
|
||||
}
|
||||
|
||||
function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
return function (event: T) {
|
||||
event.preventDefault();
|
||||
fn(event);
|
||||
};
|
||||
}
|
||||
function prevent_default<T extends Event>(fn: (event: T) => void) {
|
||||
return function (event: T) {
|
||||
event.preventDefault();
|
||||
fn(event);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<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
|
||||
onsubmit={prevent_default(() => {
|
||||
handle_search_trigger();
|
||||
})}
|
||||
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
|
||||
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
|
||||
type="search"
|
||||
placeholder="Search leads (name, email, notes)..."
|
||||
@@ -98,20 +108,18 @@
|
||||
bind:value={$events_loc.leads.tracking__qry__search_text}
|
||||
autocomplete="off"
|
||||
data-lpignore="true"
|
||||
class="input text-lg font-mono grow transition-all"
|
||||
class="input grow font-mono text-lg transition-all"
|
||||
onkeyup={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handle_search_trigger();
|
||||
}
|
||||
}}
|
||||
title="Search by name, email or notes. Press Enter."
|
||||
/>
|
||||
title="Search by name, email or notes. Press Enter." />
|
||||
|
||||
<select
|
||||
bind:value={$events_loc.leads.tracking__qry__sort_order}
|
||||
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_asc">Oldest First</option>
|
||||
<option value="name_asc">Name ASC</option>
|
||||
@@ -121,8 +129,7 @@
|
||||
<select
|
||||
bind:value={$events_loc.leads.tracking__qry__licensee_email}
|
||||
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>
|
||||
{#if !$ae_loc.administrator_access}
|
||||
<option value="my">My Leads</option>
|
||||
@@ -136,10 +143,9 @@
|
||||
<div class="flex flex-row items-center justify-center gap-1">
|
||||
<button
|
||||
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'}
|
||||
<LoaderCircle class="animate-spin mx-1" />
|
||||
<LoaderCircle class="mx-1 animate-spin" />
|
||||
{:else}
|
||||
<Search class="mx-1" />
|
||||
{/if}
|
||||
@@ -153,9 +159,8 @@
|
||||
$events_loc.leads.tracking__qry__search_text = '';
|
||||
handle_search_trigger();
|
||||
}}
|
||||
class="btn btn-sm text-xs preset-outlined-tertiary-100-900 hover:preset-filled-tertiary-100-900 transition-all"
|
||||
title="Clear search query"
|
||||
>
|
||||
class="btn btn-sm preset-outlined-tertiary-100-900 hover:preset-filled-tertiary-100-900 text-xs transition-all"
|
||||
title="Clear search query">
|
||||
<RemoveFormatting size="1.25em" />
|
||||
<span class="hidden md:inline"> Clear </span>
|
||||
</button>
|
||||
@@ -163,20 +168,20 @@
|
||||
</form>
|
||||
|
||||
<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 -->
|
||||
<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-surface={!$events_loc.leads.show_hidden}
|
||||
onclick={() => {
|
||||
$events_loc.leads.show_hidden = !$events_loc.leads.show_hidden;
|
||||
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}
|
||||
<Eye size="1em" />
|
||||
<span>Showing Hidden</span>
|
||||
@@ -188,15 +193,13 @@
|
||||
|
||||
{#if $ae_loc.edit_mode}
|
||||
<label
|
||||
class="flex items-center gap-1 cursor-pointer bg-surface-200-800 px-2 py-1 rounded-token text-xs font-semibold"
|
||||
>
|
||||
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>
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={$events_loc.leads.tracking__qry__remote_first}
|
||||
onchange={handle_search_trigger}
|
||||
class="checkbox checkbox-sm"
|
||||
/>
|
||||
class="checkbox checkbox-sm" />
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,146 +1,167 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_manual_search.svelte
|
||||
* Manual Attendee Search for adding leads.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { Eye, LoaderCircle, Search, ShieldOff, UserPlus } from '@lucide/svelte';
|
||||
import type { ae_EventBadge } from '$lib/types/ae_types';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_manual_search.svelte
|
||||
* Manual Attendee Search for adding leads.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { Eye, LoaderCircle, Search, ShieldOff, UserPlus } from '@lucide/svelte';
|
||||
import type { ae_EventBadge } from '$lib/types/ae_types';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
on_lead_added?: (badge: ae_EventBadge) => void;
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
on_lead_added?: (badge: ae_EventBadge) => void;
|
||||
}
|
||||
|
||||
let { exhibit_id, on_lead_added }: Props = $props();
|
||||
|
||||
// Track existing leads to prevent duplicates in UI
|
||||
let existing_leads_map = $derived(
|
||||
liveQuery(async () => {
|
||||
const leads = await db_events.exhibit_tracking
|
||||
.where('event_exhibit_id')
|
||||
.equals(exhibit_id)
|
||||
.toArray();
|
||||
|
||||
// Map badge_id -> tracking_id
|
||||
const map = new Map();
|
||||
leads.forEach((l) => {
|
||||
const b_id =
|
||||
l.event_badge_id_random || l.event_badge_id?.toString();
|
||||
if (b_id)
|
||||
map.set(
|
||||
b_id,
|
||||
l.event_exhibit_tracking_id_random ||
|
||||
l.event_exhibit_tracking_id?.toString()
|
||||
);
|
||||
});
|
||||
return map;
|
||||
})
|
||||
);
|
||||
|
||||
let search_query = $state('');
|
||||
let results: ae_EventBadge[] = $state([]);
|
||||
let searching = $state(false);
|
||||
let adding_id = $state(''); // badge_id currently being added (shows spinner)
|
||||
let add_error_id = $state(''); // badge_id that failed to add (shows error)
|
||||
// Track the most recently added badge_id → tracking_id so we can show a View link
|
||||
let last_added_badge_id = $state('');
|
||||
let last_added_tracking_id = $state('');
|
||||
|
||||
async function handle_search() {
|
||||
if (!search_query.trim()) return;
|
||||
searching = true;
|
||||
add_error_id = '';
|
||||
try {
|
||||
const search_results = await events_func.search__event_badge({
|
||||
api_cfg: $ae_api,
|
||||
event_id: page.params.event_id || '',
|
||||
fulltext_search_qry_str: search_query,
|
||||
limit: 20
|
||||
});
|
||||
results = Array.isArray(search_results) ? search_results : [];
|
||||
} catch (e) {
|
||||
console.error('Badge search failed', e);
|
||||
} finally {
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function add_as_lead(badge: ae_EventBadge) {
|
||||
// Use id or id_random — whichever is populated from search results
|
||||
// NO LONGER USE "_random"
|
||||
const badge_id = badge.event_badge_id_random || badge.event_badge_id;
|
||||
if (!badge_id) {
|
||||
console.warn(
|
||||
'[add_as_lead] badge missing event_badge_id_random and event_badge_id',
|
||||
badge
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let { exhibit_id, on_lead_added }: Props = $props();
|
||||
|
||||
// Track existing leads to prevent duplicates in UI
|
||||
let existing_leads_map = $derived(
|
||||
liveQuery(async () => {
|
||||
const leads = await db_events.exhibit_tracking
|
||||
.where('event_exhibit_id')
|
||||
.equals(exhibit_id)
|
||||
.toArray();
|
||||
|
||||
// Map badge_id -> tracking_id
|
||||
const map = new Map();
|
||||
leads.forEach(l => {
|
||||
const b_id = l.event_badge_id_random || l.event_badge_id?.toString();
|
||||
if (b_id) map.set(b_id, l.event_exhibit_tracking_id_random || l.event_exhibit_tracking_id?.toString());
|
||||
});
|
||||
return map;
|
||||
})
|
||||
);
|
||||
|
||||
let search_query = $state('');
|
||||
let results: ae_EventBadge[] = $state([]);
|
||||
let searching = $state(false);
|
||||
let adding_id = $state(''); // badge_id currently being added (shows spinner)
|
||||
let add_error_id = $state(''); // badge_id that failed to add (shows error)
|
||||
// Track the most recently added badge_id → tracking_id so we can show a View link
|
||||
let last_added_badge_id = $state('');
|
||||
let last_added_tracking_id = $state('');
|
||||
|
||||
async function handle_search() {
|
||||
if (!search_query.trim()) return;
|
||||
searching = true;
|
||||
add_error_id = '';
|
||||
try {
|
||||
const search_results = await events_func.search__event_badge({
|
||||
api_cfg: $ae_api,
|
||||
event_id: page.params.event_id || '',
|
||||
fulltext_search_qry_str: search_query,
|
||||
limit: 20
|
||||
});
|
||||
results = Array.isArray(search_results) ? search_results : [];
|
||||
} catch (e) {
|
||||
console.error('Badge search failed', e);
|
||||
} finally {
|
||||
searching = false;
|
||||
}
|
||||
// Gate: attendee must have opted in to lead tracking (allow_tracking must be explicitly true).
|
||||
// Defensive guard — the UI already hides the Add button for blocked badges,
|
||||
// but this prevents any direct/programmatic calls from bypassing the check.
|
||||
if (badge.allow_tracking !== true) {
|
||||
console.warn(
|
||||
'[add_as_lead] blocked — allow_tracking is not true for badge',
|
||||
badge_id
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
async function add_as_lead(badge: ae_EventBadge) {
|
||||
// Use id or id_random — whichever is populated from search results
|
||||
// NO LONGER USE "_random"
|
||||
const badge_id = badge.event_badge_id_random || badge.event_badge_id;
|
||||
if (!badge_id) {
|
||||
console.warn('[add_as_lead] badge missing event_badge_id_random and event_badge_id', badge);
|
||||
return;
|
||||
}
|
||||
adding_id = badge_id;
|
||||
add_error_id = '';
|
||||
|
||||
// Gate: attendee must have opted in to lead tracking (allow_tracking must be explicitly true).
|
||||
// Defensive guard — the UI already hides the Add button for blocked badges,
|
||||
// but this prevents any direct/programmatic calls from bypassing the check.
|
||||
if (badge.allow_tracking !== true) {
|
||||
console.warn('[add_as_lead] blocked — allow_tracking is not true for badge', badge_id);
|
||||
return;
|
||||
}
|
||||
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
|
||||
const user_email =
|
||||
kv?.type === 'licensed' && kv.key
|
||||
? kv.key
|
||||
: kv?.type === 'shared'
|
||||
? 'shared_passcode'
|
||||
: $ae_loc.access_type || 'anonymous';
|
||||
|
||||
adding_id = badge_id;
|
||||
add_error_id = '';
|
||||
try {
|
||||
const result = await events_func.create_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: exhibit_id,
|
||||
event_badge_id: badge_id,
|
||||
external_person_id: user_email,
|
||||
group: user_email
|
||||
});
|
||||
|
||||
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
|
||||
const user_email = kv?.type === 'licensed' && kv.key ? kv.key
|
||||
: kv?.type === 'shared' ? 'shared_passcode'
|
||||
: $ae_loc.access_type || 'anonymous';
|
||||
|
||||
try {
|
||||
const result = await events_func.create_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: exhibit_id,
|
||||
event_badge_id: badge_id,
|
||||
external_person_id: user_email,
|
||||
group: user_email
|
||||
});
|
||||
|
||||
if (result) {
|
||||
// Surface a View Details link next to this result row
|
||||
last_added_badge_id = badge_id;
|
||||
last_added_tracking_id = result.event_exhibit_tracking_id_random || String(result.event_exhibit_tracking_id || '');
|
||||
if (on_lead_added) on_lead_added(badge);
|
||||
} else {
|
||||
// API returned null/false — surface a visible error on this row
|
||||
add_error_id = badge_id;
|
||||
console.warn('[add_as_lead] API returned null for badge_id', badge_id);
|
||||
}
|
||||
} catch (e) {
|
||||
if (result) {
|
||||
// Surface a View Details link next to this result row
|
||||
last_added_badge_id = badge_id;
|
||||
last_added_tracking_id =
|
||||
result.event_exhibit_tracking_id_random ||
|
||||
String(result.event_exhibit_tracking_id || '');
|
||||
if (on_lead_added) on_lead_added(badge);
|
||||
} else {
|
||||
// API returned null/false — surface a visible error on this row
|
||||
add_error_id = badge_id;
|
||||
console.error('[add_as_lead] Failed to add lead', e);
|
||||
} finally {
|
||||
adding_id = '';
|
||||
console.warn(
|
||||
'[add_as_lead] API returned null for badge_id',
|
||||
badge_id
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
add_error_id = badge_id;
|
||||
console.error('[add_as_lead] Failed to add lead', e);
|
||||
} finally {
|
||||
adding_id = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="lead-manual-search space-y-4 w-full">
|
||||
<div class="lead-manual-search w-full space-y-4">
|
||||
<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"
|
||||
onsubmit={(e) => { e.preventDefault(); handle_search(); }}
|
||||
>
|
||||
<div class="flex flex-col md:flex-row items-center justify-center gap-1 grow">
|
||||
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();
|
||||
}}>
|
||||
<div
|
||||
class="flex grow flex-col items-center justify-center gap-1 md:flex-row">
|
||||
<input
|
||||
type="search"
|
||||
bind:value={search_query}
|
||||
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 class="flex flex-row items-center justify-center gap-1">
|
||||
<button
|
||||
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"
|
||||
disabled={searching}
|
||||
>
|
||||
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}>
|
||||
{#if searching}
|
||||
<LoaderCircle class="animate-spin mx-1" size="1.2em" />
|
||||
<LoaderCircle class="mx-1 animate-spin" size="1.2em" />
|
||||
{:else}
|
||||
<Search class="mx-1" size="1.2em" />
|
||||
{/if}
|
||||
@@ -150,33 +171,46 @@
|
||||
</form>
|
||||
|
||||
{#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)}
|
||||
{@const badge_id = badge.event_badge_id_random || badge.event_badge_id}
|
||||
{@const existing_id = $existing_leads_map?.get(badge_id) ?? (last_added_badge_id === badge_id ? last_added_tracking_id : '')}
|
||||
<div class="card p-3 flex justify-between items-center preset-tonal-surface shadow-sm">
|
||||
{@const badge_id =
|
||||
badge.event_badge_id_random || badge.event_badge_id}
|
||||
{@const existing_id =
|
||||
$existing_leads_map?.get(badge_id) ??
|
||||
(last_added_badge_id === badge_id
|
||||
? last_added_tracking_id
|
||||
: '')}
|
||||
<div
|
||||
class="card preset-tonal-surface flex items-center justify-between p-3 shadow-sm">
|
||||
<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>
|
||||
|
||||
{#if existing_id}
|
||||
<a
|
||||
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" />
|
||||
View
|
||||
</a>
|
||||
{:else if badge.allow_tracking !== true}
|
||||
<!-- 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" />
|
||||
Opt-Out
|
||||
</span>
|
||||
{:else if add_error_id === badge_id}
|
||||
<span class="text-xs text-error-500 font-bold">Add failed — retry?
|
||||
<button type="button" class="btn btn-sm preset-outlined-error ml-1" onclick={() => add_as_lead(badge)}>
|
||||
<span class="text-error-500 text-xs font-bold"
|
||||
>Add failed — retry?
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-outlined-error ml-1"
|
||||
onclick={() => add_as_lead(badge)}>
|
||||
Retry
|
||||
</button>
|
||||
</span>
|
||||
@@ -185,8 +219,7 @@
|
||||
type="button"
|
||||
class="btn btn-sm preset-filled-success"
|
||||
disabled={!!adding_id && adding_id === badge_id}
|
||||
onclick={() => add_as_lead(badge)}
|
||||
>
|
||||
onclick={() => add_as_lead(badge)}>
|
||||
{#if adding_id === badge_id}
|
||||
<LoaderCircle class="animate-spin" size="1em" />
|
||||
{:else}
|
||||
@@ -199,6 +232,8 @@
|
||||
{/each}
|
||||
</div>
|
||||
{: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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,275 +1,319 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte
|
||||
* Badge QR Scanner for adding leads.
|
||||
*
|
||||
* scan_qualify modes (controlled by parent ae_tab__add):
|
||||
* - 'rapid': after add → auto-reset scanner (scan next person fast)
|
||||
* - 'qualify': after add → navigate to lead detail (fill notes/qualifiers)
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import Element_qr_scanner from '$lib/elements/element_qr_scanner.svelte';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import { Camera, CircleAlert, CircleCheck, Eye, LoaderCircle, RefreshCw, RotateCcw, ShieldOff, X } from '@lucide/svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type { ae_EventBadge } from '$lib/types/ae_types';
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner.svelte
|
||||
* Badge QR Scanner for adding leads.
|
||||
*
|
||||
* scan_qualify modes (controlled by parent ae_tab__add):
|
||||
* - 'rapid': after add → auto-reset scanner (scan next person fast)
|
||||
* - 'qualify': after add → navigate to lead detail (fill notes/qualifiers)
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import Element_qr_scanner from '$lib/elements/element_qr_scanner.svelte';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import {
|
||||
Camera,
|
||||
CircleAlert,
|
||||
CircleCheck,
|
||||
Eye,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
ShieldOff,
|
||||
X
|
||||
} from '@lucide/svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import type { ae_EventBadge } from '$lib/types/ae_types';
|
||||
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
scan_qualify?: 'rapid' | 'qualify' | 'auto';
|
||||
on_lead_added?: (badge: ae_EventBadge) => void;
|
||||
}
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
scan_qualify?: 'rapid' | 'qualify' | 'auto';
|
||||
on_lead_added?: (badge: ae_EventBadge) => void;
|
||||
}
|
||||
|
||||
let { exhibit_id, scan_qualify = 'rapid', on_lead_added }: Props = $props();
|
||||
let { exhibit_id, scan_qualify = 'rapid', on_lead_added }: Props = $props();
|
||||
|
||||
// Track existing leads to detect duplicates and previously-removed records.
|
||||
// Value includes tracking_id AND enabled status so we can offer re-activation.
|
||||
let existing_leads_map = $derived(
|
||||
liveQuery(async () => {
|
||||
const leads = await db_events.exhibit_tracking
|
||||
.where('event_exhibit_id')
|
||||
.equals(exhibit_id)
|
||||
.toArray();
|
||||
// Track existing leads to detect duplicates and previously-removed records.
|
||||
// Value includes tracking_id AND enabled status so we can offer re-activation.
|
||||
let existing_leads_map = $derived(
|
||||
liveQuery(async () => {
|
||||
const leads = await db_events.exhibit_tracking
|
||||
.where('event_exhibit_id')
|
||||
.equals(exhibit_id)
|
||||
.toArray();
|
||||
|
||||
const map = new SvelteMap<string, { tracking_id: string; enabled: boolean }>();
|
||||
leads.forEach(l => {
|
||||
const b_id = l.event_badge_id?.toString();
|
||||
if (b_id) map.set(b_id, {
|
||||
const map = new SvelteMap<
|
||||
string,
|
||||
{ tracking_id: string; enabled: boolean }
|
||||
>();
|
||||
leads.forEach((l) => {
|
||||
const b_id = l.event_badge_id?.toString();
|
||||
if (b_id)
|
||||
map.set(b_id, {
|
||||
tracking_id: l.event_exhibit_tracking_id?.toString() ?? '',
|
||||
// enable stored as 1/0 or true/false — !! normalises all falsy values
|
||||
enabled: !!l.enable
|
||||
});
|
||||
});
|
||||
return map;
|
||||
})
|
||||
);
|
||||
|
||||
let start_qr_scanner = $state(true);
|
||||
let scanning_status = $state('idle'); // idle, scanning, found, adding, success, error, already_added, tracking_blocked, reenable
|
||||
let found_badge: ae_EventBadge | null = $state(null);
|
||||
let existing_tracking_id = $state('');
|
||||
let new_tracking_id = $state(''); // ID of the lead just created — used for "View Details" link
|
||||
let error_msg = $state('');
|
||||
|
||||
async function handle_qr_scan_result(event: {
|
||||
detail: { result: string; entry_method: string };
|
||||
}) {
|
||||
const qr_result = event.detail.result;
|
||||
const obj = ae_util.process_data_string(qr_result);
|
||||
|
||||
if (obj && obj.type === 'event_badge' && obj.id) {
|
||||
start_qr_scanner = false;
|
||||
|
||||
// Check if already exists (enabled or disabled/removed)
|
||||
if ($existing_leads_map?.has(obj.id)) {
|
||||
const existing = $existing_leads_map.get(obj.id)!;
|
||||
existing_tracking_id = existing.tracking_id;
|
||||
// Distinguish: active lead vs previously-removed lead
|
||||
scanning_status = existing.enabled ? 'already_added' : 'reenable';
|
||||
} else {
|
||||
scanning_status = 'found';
|
||||
}
|
||||
|
||||
// Load full badge info (needed for allow_tracking check and display)
|
||||
try {
|
||||
found_badge = await events_func.load_ae_obj_id__event_badge({
|
||||
api_cfg: $ae_api,
|
||||
event_badge_id: obj.id,
|
||||
log_lvl: 1
|
||||
});
|
||||
return map;
|
||||
})
|
||||
);
|
||||
|
||||
let start_qr_scanner = $state(true);
|
||||
let scanning_status = $state('idle'); // idle, scanning, found, adding, success, error, already_added, tracking_blocked, reenable
|
||||
let found_badge: ae_EventBadge | null = $state(null);
|
||||
let existing_tracking_id = $state('');
|
||||
let new_tracking_id = $state(''); // ID of the lead just created — used for "View Details" link
|
||||
let error_msg = $state('');
|
||||
|
||||
async function handle_qr_scan_result(event: { detail: { result: string; entry_method: string } }) {
|
||||
const qr_result = event.detail.result;
|
||||
const obj = ae_util.process_data_string(qr_result);
|
||||
|
||||
if (obj && obj.type === 'event_badge' && obj.id) {
|
||||
start_qr_scanner = false;
|
||||
|
||||
// Check if already exists (enabled or disabled/removed)
|
||||
if ($existing_leads_map?.has(obj.id)) {
|
||||
const existing = $existing_leads_map.get(obj.id)!;
|
||||
existing_tracking_id = existing.tracking_id;
|
||||
// Distinguish: active lead vs previously-removed lead
|
||||
scanning_status = existing.enabled ? 'already_added' : 'reenable';
|
||||
} else {
|
||||
scanning_status = 'found';
|
||||
// Gate: attendee must have opted in to lead tracking.
|
||||
// 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.
|
||||
if (
|
||||
scanning_status === 'found' &&
|
||||
found_badge?.allow_tracking !== true
|
||||
) {
|
||||
scanning_status = 'tracking_blocked';
|
||||
}
|
||||
|
||||
// Load full badge info (needed for allow_tracking check and display)
|
||||
// Auto mode: skip the confirm card — add immediately if tracking is allowed.
|
||||
if (scanning_status === 'found' && scan_qualify === 'auto') {
|
||||
await confirm_add_lead();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load badge info', e);
|
||||
}
|
||||
} else {
|
||||
scanning_status = 'error';
|
||||
error_msg = 'Invalid QR code. Please scan an Event Badge.';
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm_add_lead(dest: 'scan_next' | 'view_lead' = 'scan_next') {
|
||||
if (!found_badge || !found_badge.event_badge_id) {
|
||||
console.warn(
|
||||
'[leads] Guard failed — event_badge_id missing. found_badge:',
|
||||
found_badge
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
scanning_status = 'adding';
|
||||
|
||||
// Resolve who is capturing this lead:
|
||||
// licensed exhibit user → their email (kv.key)
|
||||
// shared passcode → 'shared_passcode' label (don't store the actual passcode)
|
||||
// Aether user (no kv) → access_type string ('trusted', 'manager', 'super', etc.)
|
||||
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
|
||||
const user_email =
|
||||
kv?.type === 'licensed' && kv.key
|
||||
? kv.key
|
||||
: kv?.type === 'shared'
|
||||
? 'shared_passcode'
|
||||
: $ae_loc.access_type || 'anonymous';
|
||||
|
||||
try {
|
||||
const result = await events_func.create_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: exhibit_id,
|
||||
event_badge_id: found_badge.event_badge_id,
|
||||
external_person_id: user_email,
|
||||
group: user_email
|
||||
});
|
||||
|
||||
if (result) {
|
||||
// Capture the new tracking ID so we can link to it
|
||||
new_tracking_id = String(result.event_exhibit_tracking_id || '');
|
||||
scanning_status = 'success';
|
||||
if (on_lead_added) on_lead_added(found_badge);
|
||||
|
||||
if (dest === 'view_lead' && new_tracking_id) {
|
||||
// View Lead: navigate directly to lead detail to fill in notes/qualifiers
|
||||
goto(
|
||||
`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`
|
||||
);
|
||||
} else {
|
||||
// Scan Next / auto mode: auto-reset after 2 seconds to scan the next person
|
||||
setTimeout(reset_scanner, 2000);
|
||||
}
|
||||
} else {
|
||||
// API returned false/null — could be a duplicate of a previously-removed record
|
||||
// that isn't in the local IDB cache (e.g. after IDB clear or on a second device).
|
||||
// Search the API for a disabled tracking record for this badge before surfacing an error.
|
||||
try {
|
||||
found_badge = await events_func.load_ae_obj_id__event_badge({
|
||||
const disabled_li = await events_func.search__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
event_badge_id: obj.id,
|
||||
event_id: page.params.event_id,
|
||||
event_exhibit_id: exhibit_id,
|
||||
qry_badge_id: found_badge.event_badge_id,
|
||||
enabled: 'not_enabled',
|
||||
log_lvl: 1
|
||||
});
|
||||
|
||||
// Gate: attendee must have opted in to lead tracking.
|
||||
// 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.
|
||||
if (scanning_status === 'found' && found_badge?.allow_tracking !== true) {
|
||||
scanning_status = 'tracking_blocked';
|
||||
if (disabled_li.length > 0) {
|
||||
// Found a disabled record — offer to re-activate instead of showing an error
|
||||
existing_tracking_id = String(
|
||||
disabled_li[0].event_exhibit_tracking_id || ''
|
||||
);
|
||||
scanning_status = 'reenable';
|
||||
} else {
|
||||
scanning_status = 'error';
|
||||
error_msg =
|
||||
'Failed to add lead. Check your connection and try again.';
|
||||
}
|
||||
} catch {
|
||||
scanning_status = 'error';
|
||||
error_msg =
|
||||
'Failed to add lead. Check your connection and try again.';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
scanning_status = 'error';
|
||||
error_msg = 'Failed to add lead. They might already be added.';
|
||||
}
|
||||
}
|
||||
|
||||
// Auto mode: skip the confirm card — add immediately if tracking is allowed.
|
||||
if (scanning_status === 'found' && scan_qualify === 'auto') {
|
||||
await confirm_add_lead();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load badge info', e);
|
||||
async function confirm_reenable_lead(
|
||||
dest: 'scan_next' | 'view_lead' = 'scan_next'
|
||||
) {
|
||||
// Re-activate a lead that was previously removed (enable=false).
|
||||
// existing_tracking_id is already set from the map or the API fallback search.
|
||||
if (!existing_tracking_id) return;
|
||||
scanning_status = 'adding';
|
||||
|
||||
try {
|
||||
const result = await events_func.update_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id,
|
||||
exhibit_tracking_id: existing_tracking_id,
|
||||
data: { enable: true }
|
||||
});
|
||||
|
||||
if (result) {
|
||||
new_tracking_id = existing_tracking_id;
|
||||
scanning_status = 'success';
|
||||
if (on_lead_added && found_badge) on_lead_added(found_badge);
|
||||
if (dest === 'view_lead' && new_tracking_id) {
|
||||
goto(
|
||||
`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`
|
||||
);
|
||||
} else {
|
||||
setTimeout(reset_scanner, 2000);
|
||||
}
|
||||
} else {
|
||||
scanning_status = 'error';
|
||||
error_msg = 'Invalid QR code. Please scan an Event Badge.';
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm_add_lead(dest: 'scan_next' | 'view_lead' = 'scan_next') {
|
||||
if (!found_badge || !found_badge.event_badge_id) {
|
||||
console.warn('[leads] Guard failed — event_badge_id missing. found_badge:', found_badge);
|
||||
return;
|
||||
}
|
||||
|
||||
scanning_status = 'adding';
|
||||
|
||||
// Resolve who is capturing this lead:
|
||||
// licensed exhibit user → their email (kv.key)
|
||||
// shared passcode → 'shared_passcode' label (don't store the actual passcode)
|
||||
// Aether user (no kv) → access_type string ('trusted', 'manager', 'super', etc.)
|
||||
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
|
||||
const user_email = kv?.type === 'licensed' && kv.key ? kv.key
|
||||
: kv?.type === 'shared' ? 'shared_passcode'
|
||||
: $ae_loc.access_type || 'anonymous';
|
||||
|
||||
try {
|
||||
const result = await events_func.create_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: exhibit_id,
|
||||
event_badge_id: found_badge.event_badge_id,
|
||||
external_person_id: user_email,
|
||||
group: user_email
|
||||
});
|
||||
|
||||
if (result) {
|
||||
// Capture the new tracking ID so we can link to it
|
||||
new_tracking_id = String(result.event_exhibit_tracking_id || '');
|
||||
scanning_status = 'success';
|
||||
if (on_lead_added) on_lead_added(found_badge);
|
||||
|
||||
if (dest === 'view_lead' && new_tracking_id) {
|
||||
// View Lead: navigate directly to lead detail to fill in notes/qualifiers
|
||||
goto(`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`);
|
||||
} else {
|
||||
// Scan Next / auto mode: auto-reset after 2 seconds to scan the next person
|
||||
setTimeout(reset_scanner, 2000);
|
||||
}
|
||||
} else {
|
||||
// API returned false/null — could be a duplicate of a previously-removed record
|
||||
// that isn't in the local IDB cache (e.g. after IDB clear or on a second device).
|
||||
// Search the API for a disabled tracking record for this badge before surfacing an error.
|
||||
try {
|
||||
const disabled_li = await events_func.search__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
event_id: page.params.event_id,
|
||||
event_exhibit_id: exhibit_id,
|
||||
qry_badge_id: found_badge.event_badge_id,
|
||||
enabled: 'not_enabled',
|
||||
log_lvl: 1
|
||||
});
|
||||
if (disabled_li.length > 0) {
|
||||
// Found a disabled record — offer to re-activate instead of showing an error
|
||||
existing_tracking_id = String(disabled_li[0].event_exhibit_tracking_id || '');
|
||||
scanning_status = 'reenable';
|
||||
} else {
|
||||
scanning_status = 'error';
|
||||
error_msg = 'Failed to add lead. Check your connection and try again.';
|
||||
}
|
||||
} catch {
|
||||
scanning_status = 'error';
|
||||
error_msg = 'Failed to add lead. Check your connection and try again.';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
scanning_status = 'error';
|
||||
error_msg = 'Failed to add lead. They might already be added.';
|
||||
}
|
||||
}
|
||||
|
||||
async function confirm_reenable_lead(dest: 'scan_next' | 'view_lead' = 'scan_next') {
|
||||
// Re-activate a lead that was previously removed (enable=false).
|
||||
// existing_tracking_id is already set from the map or the API fallback search.
|
||||
if (!existing_tracking_id) return;
|
||||
scanning_status = 'adding';
|
||||
|
||||
try {
|
||||
const result = await events_func.update_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id,
|
||||
exhibit_tracking_id: existing_tracking_id,
|
||||
data: { enable: true }
|
||||
});
|
||||
|
||||
if (result) {
|
||||
new_tracking_id = existing_tracking_id;
|
||||
scanning_status = 'success';
|
||||
if (on_lead_added && found_badge) on_lead_added(found_badge);
|
||||
if (dest === 'view_lead' && new_tracking_id) {
|
||||
goto(`/events/${page.params.event_id}/leads/exhibit/${exhibit_id}/lead/${new_tracking_id}`);
|
||||
} else {
|
||||
setTimeout(reset_scanner, 2000);
|
||||
}
|
||||
} else {
|
||||
scanning_status = 'error';
|
||||
error_msg = 'Failed to restore lead. Please try again.';
|
||||
}
|
||||
} catch {
|
||||
scanning_status = 'error';
|
||||
error_msg = 'Failed to restore lead. Please try again.';
|
||||
}
|
||||
} catch {
|
||||
scanning_status = 'error';
|
||||
error_msg = 'Failed to restore lead. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
function reset_scanner() {
|
||||
scanning_status = 'idle';
|
||||
found_badge = null;
|
||||
new_tracking_id = '';
|
||||
error_msg = '';
|
||||
start_qr_scanner = true;
|
||||
}
|
||||
function reset_scanner() {
|
||||
scanning_status = 'idle';
|
||||
found_badge = null;
|
||||
new_tracking_id = '';
|
||||
error_msg = '';
|
||||
start_qr_scanner = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="lead-qr-scanner flex flex-col items-center space-y-4 w-full min-h-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'}
|
||||
<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
|
||||
bind:start_qr_scanner
|
||||
on_qr_scan_result={handle_qr_scan_result}
|
||||
/>
|
||||
<div class="absolute inset-0 pointer-events-none border-2 border-primary-500/50 m-8 sm:m-12 rounded-lg animate-pulse"></div>
|
||||
on_qr_scan_result={handle_qr_scan_result} />
|
||||
<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>
|
||||
|
||||
<p class="text-center text-sm italic opacity-70">
|
||||
Point camera at the badge QR code
|
||||
</p>
|
||||
{: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 class="text-center space-y-2">
|
||||
<ShieldOff size="3em" class="mx-auto text-warning-500" />
|
||||
<div
|
||||
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">
|
||||
<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>
|
||||
<p class="text-xl font-bold">{found_badge?.full_name || 'Attendee'}</p>
|
||||
<p class="opacity-70 text-sm">
|
||||
<p class="text-xl font-bold">
|
||||
{found_badge?.full_name || 'Attendee'}
|
||||
</p>
|
||||
<p class="text-sm opacity-70">
|
||||
This attendee has opted out of exhibitor lead scanning.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn w-full preset-filled-warning font-bold"
|
||||
onclick={reset_scanner}
|
||||
>
|
||||
class="btn preset-filled-warning w-full font-bold"
|
||||
onclick={reset_scanner}>
|
||||
<Camera size="1.2em" />
|
||||
Scan Next
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{: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 class="text-center space-y-2">
|
||||
<RotateCcw size="3em" class="mx-auto text-warning-500" />
|
||||
<div
|
||||
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">
|
||||
<div class="space-y-2 text-center">
|
||||
<RotateCcw size="3em" class="text-warning-500 mx-auto" />
|
||||
<h3 class="h3 font-bold">Previously Removed</h3>
|
||||
<p class="text-xl font-bold">{found_badge?.full_name || 'Attendee'}</p>
|
||||
<p class="opacity-70 text-sm">This lead was removed. Re-activate to restore their record including any saved notes and responses.</p>
|
||||
<p class="text-xl font-bold">
|
||||
{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>
|
||||
|
||||
<!-- Two-button confirm — same pattern as the main confirm card -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<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"
|
||||
onclick={() => confirm_reenable_lead('scan_next')}
|
||||
>
|
||||
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')}>
|
||||
<Camera size="1.5em" />
|
||||
Restore & Scan Next
|
||||
</button>
|
||||
<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"
|
||||
onclick={() => confirm_reenable_lead('view_lead')}
|
||||
>
|
||||
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')}>
|
||||
<Eye size="1.5em" />
|
||||
Restore & View Lead
|
||||
</button>
|
||||
@@ -277,27 +321,29 @@
|
||||
|
||||
<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"
|
||||
onclick={reset_scanner}
|
||||
>
|
||||
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}>
|
||||
<X size="1em" />
|
||||
Cancel / Scan Again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{: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 class="text-center space-y-2">
|
||||
<CircleCheck size="3em" class="mx-auto text-secondary-500" />
|
||||
<div
|
||||
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">
|
||||
<div class="space-y-2 text-center">
|
||||
<CircleCheck size="3em" class="text-secondary-500 mx-auto" />
|
||||
<h3 class="h3 font-bold">Already Captured</h3>
|
||||
<p class="text-xl font-bold">{found_badge?.full_name || 'Attendee'}</p>
|
||||
<p class="opacity-70 text-sm">This attendee is already in your leads list.</p>
|
||||
<p class="text-xl font-bold">
|
||||
{found_badge?.full_name || 'Attendee'}
|
||||
</p>
|
||||
<p class="text-sm opacity-70">
|
||||
This attendee is already in your leads list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
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" />
|
||||
View Lead Details
|
||||
</a>
|
||||
@@ -305,46 +351,49 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm w-full opacity-50"
|
||||
onclick={reset_scanner}
|
||||
>
|
||||
onclick={reset_scanner}>
|
||||
<Camera size="1em" />
|
||||
Scan Next
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if scanning_status === 'found' || scanning_status === 'adding'}
|
||||
<!-- 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. -->
|
||||
<!-- Buttons use direct Tailwind tokens, not btn/preset-*, because the Skeleton
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{#if scan_qualify === 'auto' || scanning_status === 'adding'}
|
||||
<!-- 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" />
|
||||
<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>
|
||||
{:else}
|
||||
<!-- Two-button confirm: staff chooses what to do after adding this lead -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<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"
|
||||
onclick={() => confirm_add_lead('scan_next')}
|
||||
>
|
||||
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')}>
|
||||
<Camera size="1.5em" />
|
||||
Add & Scan Next
|
||||
</button>
|
||||
<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"
|
||||
onclick={() => confirm_add_lead('view_lead')}
|
||||
>
|
||||
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')}>
|
||||
<Eye size="1.5em" />
|
||||
Add & View Lead
|
||||
</button>
|
||||
@@ -352,19 +401,20 @@
|
||||
|
||||
<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"
|
||||
onclick={reset_scanner}
|
||||
>
|
||||
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}>
|
||||
<X size="1em" />
|
||||
Cancel / Scan Again
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{: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 class="p-10 w-full flex flex-col items-center space-y-4">
|
||||
<CircleCheck size="4em" class="text-success-500 animate-bounce" />
|
||||
<div
|
||||
class="card preset-tonal-success flex w-full max-w-md flex-col items-center space-y-4 overflow-hidden shadow-xl">
|
||||
<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">
|
||||
<h3 class="h4 font-bold">Lead Added!</h3>
|
||||
<p class="text-xl font-bold">{found_badge?.full_name}</p>
|
||||
@@ -373,26 +423,31 @@
|
||||
{#if new_tracking_id}
|
||||
<a
|
||||
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" />
|
||||
View Details
|
||||
</a>
|
||||
{/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>
|
||||
<!-- 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. -->
|
||||
<div class="w-full h-1.5 bg-success-200/40">
|
||||
<div class="h-full bg-success-500 scanner-reset-countdown"></div>
|
||||
<div class="bg-success-200/40 h-1.5 w-full">
|
||||
<div class="bg-success-500 scanner-reset-countdown h-full">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{: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" />
|
||||
<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" />
|
||||
Try Again
|
||||
</button>
|
||||
@@ -401,13 +456,17 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Countdown bar for the rapid-mode success card.
|
||||
/* Countdown bar for the rapid-mode success card.
|
||||
Must match the 2000ms setTimeout in reset_scanner(). */
|
||||
.scanner-reset-countdown {
|
||||
animation: scanner-reset-countdown 2s linear forwards;
|
||||
.scanner-reset-countdown {
|
||||
animation: scanner-reset-countdown 2s linear forwards;
|
||||
}
|
||||
@keyframes scanner-reset-countdown {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
@keyframes scanner-reset-countdown {
|
||||
from { width: 100%; }
|
||||
to { width: 0%; }
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,298 +1,353 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte
|
||||
* Multi-badge batch QR scanner.
|
||||
*
|
||||
* Uses the native BarcodeDetector API (Chrome/Edge/Safari 17+) to detect multiple
|
||||
* QR codes in a single camera frame. Staff lay 1–4 badges flat in view, tap
|
||||
* "Capture Batch", and a grid of confirm cards appears — one per detected badge.
|
||||
* Cards can be individually added, skipped, or dismissed; "Add All" handles the batch.
|
||||
*
|
||||
* Hard cap: 8 badges per batch (browser/camera support varies; 4 is the practical ask).
|
||||
*
|
||||
* Firefox: BarcodeDetector not yet supported — shows an informative fallback.
|
||||
*/
|
||||
import { onDestroy } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import type { ae_EventBadge } from '$lib/types/ae_types';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import {
|
||||
CircleCheck, Eye, Layers, LoaderCircle,
|
||||
RefreshCw, RotateCcw, ScanLine, ShieldOff, UserPlus, X
|
||||
} from '@lucide/svelte';
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__lead_qr_scanner_multi.svelte
|
||||
* Multi-badge batch QR scanner.
|
||||
*
|
||||
* Uses the native BarcodeDetector API (Chrome/Edge/Safari 17+) to detect multiple
|
||||
* QR codes in a single camera frame. Staff lay 1–4 badges flat in view, tap
|
||||
* "Capture Batch", and a grid of confirm cards appears — one per detected badge.
|
||||
* Cards can be individually added, skipped, or dismissed; "Add All" handles the batch.
|
||||
*
|
||||
* Hard cap: 8 badges per batch (browser/camera support varies; 4 is the practical ask).
|
||||
*
|
||||
* Firefox: BarcodeDetector not yet supported — shows an informative fallback.
|
||||
*/
|
||||
import { onDestroy } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import type { ae_EventBadge } from '$lib/types/ae_types';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import {
|
||||
CircleCheck,
|
||||
Eye,
|
||||
Layers,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
ScanLine,
|
||||
ShieldOff,
|
||||
UserPlus,
|
||||
X
|
||||
} from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
on_lead_added?: (badge: ae_EventBadge) => void;
|
||||
}
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
on_lead_added?: (badge: ae_EventBadge) => void;
|
||||
}
|
||||
|
||||
let { exhibit_id, on_lead_added }: Props = $props();
|
||||
let { exhibit_id, on_lead_added }: Props = $props();
|
||||
|
||||
// BarcodeDetector is in Chrome/Edge/Safari 17+; not yet in Firefox.
|
||||
// Check at runtime — TypeScript lib.dom.d.ts may not have it yet.
|
||||
const is_supported = typeof window !== 'undefined' && 'BarcodeDetector' in window;
|
||||
// BarcodeDetector is in Chrome/Edge/Safari 17+; not yet in Firefox.
|
||||
// Check at runtime — TypeScript lib.dom.d.ts may not have it yet.
|
||||
const is_supported =
|
||||
typeof window !== 'undefined' && 'BarcodeDetector' in window;
|
||||
|
||||
// --- Types ---
|
||||
type BatchStatus = 'loading' | 'ready' | 'blocked' | 'already_added' | 'reenable' | 'adding' | 'added' | 'error';
|
||||
// --- Types ---
|
||||
type BatchStatus =
|
||||
| 'loading'
|
||||
| 'ready'
|
||||
| 'blocked'
|
||||
| 'already_added'
|
||||
| 'reenable'
|
||||
| 'adding'
|
||||
| 'added'
|
||||
| 'error';
|
||||
|
||||
interface BatchItem {
|
||||
id: string; // badge id_random from QR
|
||||
badge: ae_EventBadge | null;
|
||||
status: BatchStatus;
|
||||
existing_tracking_id: string; // set when status === 'already_added'
|
||||
dismissing: boolean; // true while CSS fade-out plays
|
||||
}
|
||||
interface BatchItem {
|
||||
id: string; // badge id_random from QR
|
||||
badge: ae_EventBadge | null;
|
||||
status: BatchStatus;
|
||||
existing_tracking_id: string; // set when status === 'already_added'
|
||||
dismissing: boolean; // true while CSS fade-out plays
|
||||
}
|
||||
|
||||
// --- Existing leads (duplicate detection) ---
|
||||
// Value includes tracking_id AND enabled status so we can offer re-activation for removed leads.
|
||||
let existing_leads_map = $derived(
|
||||
liveQuery(async () => {
|
||||
const leads = await db_events.exhibit_tracking
|
||||
.where('event_exhibit_id').equals(exhibit_id).toArray();
|
||||
const map = new SvelteMap<string, { tracking_id: string; enabled: boolean }>();
|
||||
leads.forEach(l => {
|
||||
const b_id = l.event_badge_id?.toString();
|
||||
if (b_id) map.set(b_id, {
|
||||
// --- Existing leads (duplicate detection) ---
|
||||
// Value includes tracking_id AND enabled status so we can offer re-activation for removed leads.
|
||||
let existing_leads_map = $derived(
|
||||
liveQuery(async () => {
|
||||
const leads = await db_events.exhibit_tracking
|
||||
.where('event_exhibit_id')
|
||||
.equals(exhibit_id)
|
||||
.toArray();
|
||||
const map = new SvelteMap<
|
||||
string,
|
||||
{ tracking_id: string; enabled: boolean }
|
||||
>();
|
||||
leads.forEach((l) => {
|
||||
const b_id = l.event_badge_id?.toString();
|
||||
if (b_id)
|
||||
map.set(b_id, {
|
||||
tracking_id: l.event_exhibit_tracking_id?.toString() || '',
|
||||
enabled: !!l.enable
|
||||
});
|
||||
});
|
||||
return map;
|
||||
})
|
||||
);
|
||||
});
|
||||
return map;
|
||||
})
|
||||
);
|
||||
|
||||
// --- Camera ---
|
||||
let video_el = $state<HTMLVideoElement | undefined>(undefined);
|
||||
let stream: MediaStream | null = null;
|
||||
let detector: any = null;
|
||||
let camera_status = $state<'idle' | 'starting' | 'live' | 'capturing' | 'error'>('idle');
|
||||
let camera_error = $state('');
|
||||
// --- Camera ---
|
||||
let video_el = $state<HTMLVideoElement | undefined>(undefined);
|
||||
let stream: MediaStream | null = null;
|
||||
let detector: any = null;
|
||||
let camera_status = $state<
|
||||
'idle' | 'starting' | 'live' | 'capturing' | 'error'
|
||||
>('idle');
|
||||
let camera_error = $state('');
|
||||
|
||||
// Start camera when the video element mounts
|
||||
$effect(() => {
|
||||
if (!video_el || !is_supported) return;
|
||||
start_camera();
|
||||
return () => stop_camera();
|
||||
});
|
||||
// Start camera when the video element mounts
|
||||
$effect(() => {
|
||||
if (!video_el || !is_supported) return;
|
||||
start_camera();
|
||||
return () => stop_camera();
|
||||
});
|
||||
|
||||
onDestroy(stop_camera);
|
||||
onDestroy(stop_camera);
|
||||
|
||||
async function start_camera() {
|
||||
if (camera_status !== 'idle') return;
|
||||
camera_status = 'starting';
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment', width: { ideal: 1920 }, height: { ideal: 1080 } }
|
||||
});
|
||||
if (!video_el) { stop_camera(); return; }
|
||||
video_el.srcObject = stream;
|
||||
await video_el.play();
|
||||
// BarcodeDetector API — not yet typed in lib.dom.d.ts for all targets
|
||||
detector = new (window as any).BarcodeDetector({ formats: ['qr_code'] });
|
||||
camera_status = 'live';
|
||||
} catch (e: any) {
|
||||
camera_status = 'error';
|
||||
camera_error = e?.name === 'NotAllowedError'
|
||||
async function start_camera() {
|
||||
if (camera_status !== 'idle') return;
|
||||
camera_status = 'starting';
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: 'environment',
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 }
|
||||
}
|
||||
});
|
||||
if (!video_el) {
|
||||
stop_camera();
|
||||
return;
|
||||
}
|
||||
video_el.srcObject = stream;
|
||||
await video_el.play();
|
||||
// BarcodeDetector API — not yet typed in lib.dom.d.ts for all targets
|
||||
detector = new (window as any).BarcodeDetector({
|
||||
formats: ['qr_code']
|
||||
});
|
||||
camera_status = 'live';
|
||||
} catch (e: any) {
|
||||
camera_status = 'error';
|
||||
camera_error =
|
||||
e?.name === 'NotAllowedError'
|
||||
? 'Camera access denied. Allow camera access and try again.'
|
||||
: 'Could not start camera. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
function stop_camera() {
|
||||
stream?.getTracks().forEach((t) => t.stop());
|
||||
stream = null;
|
||||
detector = null;
|
||||
if (camera_status !== 'error') camera_status = 'idle';
|
||||
}
|
||||
|
||||
async function retry_camera() {
|
||||
camera_status = 'idle';
|
||||
await start_camera();
|
||||
}
|
||||
|
||||
// --- Batch ---
|
||||
let batch = $state<BatchItem[]>([]);
|
||||
let ready_count = $derived(
|
||||
batch.filter((i) => i.status === 'ready' && !i.dismissing).length
|
||||
);
|
||||
|
||||
async function capture_batch() {
|
||||
if (!detector || !video_el || camera_status !== 'live') return;
|
||||
camera_status = 'capturing';
|
||||
|
||||
try {
|
||||
const barcodes: Array<{ rawValue: string }> =
|
||||
await detector.detect(video_el);
|
||||
const existing_ids = new Set(batch.map((i) => i.id));
|
||||
|
||||
const new_objs = barcodes
|
||||
.map((b) => ae_util.process_data_string(b.rawValue))
|
||||
.filter(
|
||||
(obj): obj is { type: string; id: string } =>
|
||||
!!(obj && obj.type === 'event_badge' && obj.id)
|
||||
)
|
||||
.filter((obj) => !existing_ids.has(obj.id))
|
||||
.slice(0, Math.max(0, 8 - batch.length)); // hard cap at 8 total
|
||||
|
||||
for (const obj of new_objs) {
|
||||
const item: BatchItem = {
|
||||
id: obj.id,
|
||||
badge: null,
|
||||
status: 'loading',
|
||||
existing_tracking_id: '',
|
||||
dismissing: false
|
||||
};
|
||||
batch.push(item);
|
||||
load_badge(item); // fire-and-forget; updates item in place
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Multi] BarcodeDetector.detect failed:', e);
|
||||
}
|
||||
|
||||
function stop_camera() {
|
||||
stream?.getTracks().forEach(t => t.stop());
|
||||
stream = null;
|
||||
detector = null;
|
||||
if (camera_status !== 'error') camera_status = 'idle';
|
||||
}
|
||||
camera_status = 'live';
|
||||
}
|
||||
|
||||
async function retry_camera() {
|
||||
camera_status = 'idle';
|
||||
await start_camera();
|
||||
}
|
||||
async function load_badge(item: BatchItem) {
|
||||
try {
|
||||
const badge = await events_func.load_ae_obj_id__event_badge({
|
||||
api_cfg: $ae_api,
|
||||
event_badge_id: item.id,
|
||||
log_lvl: 1
|
||||
});
|
||||
item.badge = badge;
|
||||
|
||||
// --- Batch ---
|
||||
let batch = $state<BatchItem[]>([]);
|
||||
let ready_count = $derived(batch.filter(i => i.status === 'ready' && !i.dismissing).length);
|
||||
|
||||
async function capture_batch() {
|
||||
if (!detector || !video_el || camera_status !== 'live') return;
|
||||
camera_status = 'capturing';
|
||||
|
||||
try {
|
||||
const barcodes: Array<{ rawValue: string }> = await detector.detect(video_el);
|
||||
const existing_ids = new Set(batch.map(i => i.id));
|
||||
|
||||
const new_objs = barcodes
|
||||
.map(b => ae_util.process_data_string(b.rawValue))
|
||||
.filter((obj): obj is { type: string; id: string } =>
|
||||
!!(obj && obj.type === 'event_badge' && obj.id))
|
||||
.filter(obj => !existing_ids.has(obj.id))
|
||||
.slice(0, Math.max(0, 8 - batch.length)); // hard cap at 8 total
|
||||
|
||||
for (const obj of new_objs) {
|
||||
const item: BatchItem = {
|
||||
id: obj.id,
|
||||
badge: null,
|
||||
status: 'loading',
|
||||
existing_tracking_id: '',
|
||||
dismissing: false
|
||||
};
|
||||
batch.push(item);
|
||||
load_badge(item); // fire-and-forget; updates item in place
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Multi] BarcodeDetector.detect failed:', e);
|
||||
if ($existing_leads_map?.has(item.id)) {
|
||||
const existing = $existing_leads_map.get(item.id)!;
|
||||
item.existing_tracking_id = existing.tracking_id;
|
||||
// Distinguish: active lead vs previously-removed lead
|
||||
item.status = existing.enabled ? 'already_added' : 'reenable';
|
||||
} else if (badge?.allow_tracking !== true) {
|
||||
// Attendee has opted out — show card so staff can inform them
|
||||
item.status = 'blocked';
|
||||
} else {
|
||||
item.status = 'ready';
|
||||
}
|
||||
|
||||
camera_status = 'live';
|
||||
} catch {
|
||||
item.status = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function load_badge(item: BatchItem) {
|
||||
try {
|
||||
const badge = await events_func.load_ae_obj_id__event_badge({
|
||||
api_cfg: $ae_api,
|
||||
event_badge_id: item.id,
|
||||
log_lvl: 1
|
||||
});
|
||||
item.badge = badge;
|
||||
async function add_lead(item: BatchItem) {
|
||||
if (item.status !== 'ready' || !item.badge?.event_badge_id) return;
|
||||
item.status = 'adding';
|
||||
|
||||
if ($existing_leads_map?.has(item.id)) {
|
||||
const existing = $existing_leads_map.get(item.id)!;
|
||||
item.existing_tracking_id = existing.tracking_id;
|
||||
// Distinguish: active lead vs previously-removed lead
|
||||
item.status = existing.enabled ? 'already_added' : 'reenable';
|
||||
} else if (badge?.allow_tracking !== true) {
|
||||
// Attendee has opted out — show card so staff can inform them
|
||||
item.status = 'blocked';
|
||||
} else {
|
||||
item.status = 'ready';
|
||||
}
|
||||
} catch {
|
||||
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
|
||||
const user_email =
|
||||
kv?.type === 'licensed' && kv.key
|
||||
? kv.key
|
||||
: kv?.type === 'shared'
|
||||
? 'shared_passcode'
|
||||
: $ae_loc.access_type || 'anonymous';
|
||||
try {
|
||||
const result = await events_func.create_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id,
|
||||
event_badge_id: item.badge.event_badge_id,
|
||||
external_person_id: user_email,
|
||||
group: user_email
|
||||
});
|
||||
if (result) {
|
||||
item.status = 'added';
|
||||
if (on_lead_added) on_lead_added(item.badge);
|
||||
// Brief success display, then fade out
|
||||
setTimeout(() => dismiss_item(item), 1000);
|
||||
} else {
|
||||
// API returned false/null — network error, API down, or auth failure.
|
||||
item.status = 'error';
|
||||
}
|
||||
} catch {
|
||||
item.status = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function add_lead(item: BatchItem) {
|
||||
if (item.status !== 'ready' || !item.badge?.event_badge_id) return;
|
||||
item.status = 'adding';
|
||||
async function reenable_lead(item: BatchItem) {
|
||||
if (!item.existing_tracking_id) return;
|
||||
item.status = 'adding';
|
||||
|
||||
const kv = $events_loc.leads.auth_exhibit_kv?.[exhibit_id];
|
||||
const user_email = kv?.type === 'licensed' && kv.key ? kv.key
|
||||
: kv?.type === 'shared' ? 'shared_passcode'
|
||||
: $ae_loc.access_type || 'anonymous';
|
||||
try {
|
||||
const result = await events_func.create_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id,
|
||||
event_badge_id: item.badge.event_badge_id,
|
||||
external_person_id: user_email,
|
||||
group: user_email
|
||||
});
|
||||
if (result) {
|
||||
item.status = 'added';
|
||||
if (on_lead_added) on_lead_added(item.badge);
|
||||
// Brief success display, then fade out
|
||||
setTimeout(() => dismiss_item(item), 1000);
|
||||
} else {
|
||||
// API returned false/null — network error, API down, or auth failure.
|
||||
item.status = 'error';
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const result = await events_func.update_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id,
|
||||
exhibit_tracking_id: item.existing_tracking_id,
|
||||
data: { enable: true }
|
||||
});
|
||||
if (result) {
|
||||
item.status = 'added';
|
||||
if (on_lead_added && item.badge) on_lead_added(item.badge);
|
||||
setTimeout(() => dismiss_item(item), 1000);
|
||||
} else {
|
||||
item.status = 'error';
|
||||
}
|
||||
} catch {
|
||||
item.status = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function reenable_lead(item: BatchItem) {
|
||||
if (!item.existing_tracking_id) return;
|
||||
item.status = 'adding';
|
||||
async function add_all() {
|
||||
const to_add = batch.filter((i) => i.status === 'ready' && !i.dismissing);
|
||||
await Promise.all(to_add.map(add_lead));
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await events_func.update_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id,
|
||||
exhibit_tracking_id: item.existing_tracking_id,
|
||||
data: { enable: true }
|
||||
});
|
||||
if (result) {
|
||||
item.status = 'added';
|
||||
if (on_lead_added && item.badge) on_lead_added(item.badge);
|
||||
setTimeout(() => dismiss_item(item), 1000);
|
||||
} else {
|
||||
item.status = 'error';
|
||||
}
|
||||
} catch {
|
||||
item.status = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
async function add_all() {
|
||||
const to_add = batch.filter(i => i.status === 'ready' && !i.dismissing);
|
||||
await Promise.all(to_add.map(add_lead));
|
||||
}
|
||||
|
||||
function dismiss_item(item: BatchItem) {
|
||||
item.dismissing = true;
|
||||
// Remove from array after CSS transition completes (300ms)
|
||||
setTimeout(() => {
|
||||
const idx = batch.findIndex(i => i.id === item.id);
|
||||
if (idx >= 0) batch.splice(idx, 1);
|
||||
}, 350);
|
||||
}
|
||||
function dismiss_item(item: BatchItem) {
|
||||
item.dismissing = true;
|
||||
// Remove from array after CSS transition completes (300ms)
|
||||
setTimeout(() => {
|
||||
const idx = batch.findIndex((i) => i.id === item.id);
|
||||
if (idx >= 0) batch.splice(idx, 1);
|
||||
}, 350);
|
||||
}
|
||||
</script>
|
||||
|
||||
<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}
|
||||
<!-- 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">
|
||||
<Layers size="2.5em" class="mx-auto text-warning-500" />
|
||||
<div
|
||||
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>
|
||||
<p class="text-sm opacity-70">
|
||||
Multi-scan uses the browser's BarcodeDetector API, which is supported in
|
||||
Chrome, Edge, and Safari 17+. Firefox support is coming soon.
|
||||
Multi-scan uses the browser's BarcodeDetector API, which is
|
||||
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 class="text-sm opacity-70">Use <strong>Rapid</strong> or <strong>Auto</strong> mode in the meantime.</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
|
||||
<!-- 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 -->
|
||||
<video
|
||||
bind:this={video_el}
|
||||
class="w-full h-full object-cover"
|
||||
class="h-full w-full object-cover"
|
||||
playsinline
|
||||
muted
|
||||
></video>
|
||||
muted></video>
|
||||
|
||||
<!-- Hint overlay: shown while camera is live, styled like a check-deposit scanner guide -->
|
||||
{#if camera_status === 'live'}
|
||||
<div class="absolute inset-0 pointer-events-none flex flex-col justify-end items-center pb-3 px-4">
|
||||
<span class="bg-black/50 text-white text-xs font-semibold px-3 py-1.5 rounded-full tracking-wide text-center">
|
||||
<div
|
||||
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
|
||||
</span>
|
||||
</div>
|
||||
<!-- Corner guides — visual aid for badge alignment -->
|
||||
<div class="absolute inset-4 pointer-events-none">
|
||||
<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 class="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-primary-400/70 rounded-tr"></div>
|
||||
<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 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="pointer-events-none absolute inset-4">
|
||||
<div
|
||||
class="border-primary-400/70 absolute top-0 left-0 h-6 w-6 rounded-tl border-t-2 border-l-2">
|
||||
</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>
|
||||
{/if}
|
||||
|
||||
<!-- Starting overlay -->
|
||||
{#if camera_status === 'starting'}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-black/40">
|
||||
<span class="bg-black/60 text-white text-sm font-semibold px-4 py-2 rounded-full animate-pulse shadow-lg">
|
||||
<div
|
||||
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...
|
||||
</span>
|
||||
</div>
|
||||
@@ -300,13 +355,16 @@
|
||||
|
||||
<!-- Error overlay -->
|
||||
{#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">
|
||||
<p class="text-white text-sm font-semibold leading-snug drop-shadow">{camera_error}</p>
|
||||
<div
|
||||
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
|
||||
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"
|
||||
onclick={retry_camera}
|
||||
>
|
||||
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}>
|
||||
<RefreshCw size="1.2em" />
|
||||
Try Again
|
||||
</button>
|
||||
@@ -318,10 +376,9 @@
|
||||
{#if camera_status === 'live' || camera_status === 'capturing'}
|
||||
<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'}
|
||||
onclick={capture_batch}
|
||||
>
|
||||
onclick={capture_batch}>
|
||||
{#if camera_status === 'capturing'}
|
||||
<LoaderCircle class="animate-spin" size="1.3em" />
|
||||
Scanning...
|
||||
@@ -334,143 +391,172 @@
|
||||
|
||||
<!-- Badge grid -->
|
||||
{#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)}
|
||||
<div
|
||||
class="batch-card card p-4 space-y-3 bg-surface-50-900 border border-surface-500/20 shadow min-h-28"
|
||||
class:dismissing={item.dismissing}
|
||||
>
|
||||
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}>
|
||||
{#if item.status === 'loading'}
|
||||
<!-- Skeleton — fixed height prevents layout bounce as badges load -->
|
||||
<div class="space-y-2">
|
||||
<div class="h-5 w-3/4 bg-surface-200-800 animate-pulse rounded"></div>
|
||||
<div class="h-4 w-1/2 bg-surface-200-800 animate-pulse rounded"></div>
|
||||
<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 class="h-9 bg-surface-200-800 animate-pulse rounded-lg"></div>
|
||||
|
||||
{:else if item.status === 'blocked'}
|
||||
<!-- Tracking opt-out — show card so staff can inform the attendee -->
|
||||
<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>
|
||||
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || 'Attendee'}</p>
|
||||
<p class="text-xs opacity-60 mt-0.5">Opted out of lead scanning</p>
|
||||
<p class="text-sm leading-tight font-bold">
|
||||
{item.badge?.full_name || 'Attendee'}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs opacity-60">
|
||||
Opted out of lead scanning
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
onclick={() => dismiss_item(item)}
|
||||
>
|
||||
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)}>
|
||||
<X size="1em" />
|
||||
OK, Dismiss
|
||||
</button>
|
||||
|
||||
{:else if item.status === 'already_added'}
|
||||
<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>
|
||||
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || 'Attendee'}</p>
|
||||
<p class="text-xs opacity-60 mt-0.5">Already captured</p>
|
||||
<p class="text-sm leading-tight font-bold">
|
||||
{item.badge?.full_name || 'Attendee'}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs opacity-60">
|
||||
Already captured
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
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" />
|
||||
View
|
||||
</a>
|
||||
<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"
|
||||
onclick={() => dismiss_item(item)}
|
||||
>
|
||||
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)}>
|
||||
<X size="0.9em" />
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if item.status === 'reenable'}
|
||||
<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>
|
||||
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || 'Attendee'}</p>
|
||||
<p class="text-xs opacity-60 mt-0.5">Previously removed</p>
|
||||
<p class="text-sm leading-tight font-bold">
|
||||
{item.badge?.full_name || 'Attendee'}
|
||||
</p>
|
||||
<p class="mt-0.5 text-xs opacity-60">
|
||||
Previously removed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<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"
|
||||
onclick={() => reenable_lead(item)}
|
||||
>
|
||||
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)}>
|
||||
<RotateCcw size="0.9em" />
|
||||
Re-activate
|
||||
</button>
|
||||
<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"
|
||||
onclick={() => dismiss_item(item)}
|
||||
>
|
||||
onclick={() => dismiss_item(item)}>
|
||||
<X size="1em" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if item.status === 'ready'}
|
||||
<div>
|
||||
<p class="font-bold text-sm leading-tight">{item.badge?.full_name || 'Badge Found'}</p>
|
||||
<p class="text-xs opacity-60">{item.badge?.affiliations || ''}</p>
|
||||
<p class="text-sm leading-tight font-bold">
|
||||
{item.badge?.full_name || 'Badge Found'}
|
||||
</p>
|
||||
<p class="text-xs opacity-60">
|
||||
{item.badge?.affiliations || ''}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<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"
|
||||
onclick={() => add_lead(item)}
|
||||
>
|
||||
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)}>
|
||||
<UserPlus size="1em" />
|
||||
Add
|
||||
</button>
|
||||
<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"
|
||||
onclick={() => dismiss_item(item)}
|
||||
>
|
||||
onclick={() => dismiss_item(item)}>
|
||||
<X size="1em" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if item.status === 'adding'}
|
||||
<div class="flex items-center gap-2 py-1 opacity-70">
|
||||
<LoaderCircle size="1.2em" class="animate-spin text-primary-500 shrink-0" />
|
||||
<div
|
||||
class="flex items-center gap-2 py-1 opacity-70">
|
||||
<LoaderCircle
|
||||
size="1.2em"
|
||||
class="text-primary-500 shrink-0 animate-spin" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if item.status === 'added'}
|
||||
<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>
|
||||
<p class="font-bold text-sm leading-tight text-success-600 dark:text-success-400">{item.badge?.full_name || 'Lead'}</p>
|
||||
<p class="text-xs opacity-60">Lead added!</p>
|
||||
<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>
|
||||
|
||||
{:else if item.status === 'error'}
|
||||
<div>
|
||||
<p class="text-sm font-bold text-error-600 dark:text-error-400">Failed to add</p>
|
||||
<p class="text-xs opacity-60">{item.badge?.full_name || 'Unknown'}</p>
|
||||
<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>
|
||||
<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"
|
||||
onclick={() => dismiss_item(item)}
|
||||
>
|
||||
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)}>
|
||||
<X size="1em" />
|
||||
Dismiss
|
||||
</button>
|
||||
@@ -483,27 +569,27 @@
|
||||
{#if ready_count > 0}
|
||||
<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"
|
||||
onclick={add_all}
|
||||
>
|
||||
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}>
|
||||
<UserPlus size="1.3em" />
|
||||
Add All ({ready_count})
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Smooth fade-and-shrink when a card is dismissed (Add / Skip / OK).
|
||||
/* Smooth fade-and-shrink when a card is dismissed (Add / Skip / OK).
|
||||
Duration matches the 350ms setTimeout in dismiss_item(). */
|
||||
.batch-card {
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
.batch-card.dismissing {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
pointer-events: none;
|
||||
}
|
||||
.batch-card {
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
.batch-card.dismissing {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,81 +1,97 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte
|
||||
* Tab 2: Add - Search / QR Scan Layout.
|
||||
*
|
||||
* Two orthogonal toggles:
|
||||
* - mode: 'qr' | 'search' — how to find the attendee
|
||||
* - scan_qualify: 'rapid' | 'qualify' | 'auto' | 'multi' — what to do after finding (QR mode only)
|
||||
* rapid: confirm tap → auto-reset → scan next person immediately
|
||||
* qualify: confirm tap → navigate to lead detail → fill qualifiers/notes
|
||||
* auto: no confirm — badge is added immediately on scan → auto-reset
|
||||
* multi: BarcodeDetector batch scan → grid of confirm cards
|
||||
*/
|
||||
import { Bot, ChevronDown, Layers, QrCode, Search, Zap } from '@lucide/svelte';
|
||||
import Comp_lead_qr_scanner from './ae_comp__lead_qr_scanner.svelte';
|
||||
import Comp_lead_qr_scanner_multi from './ae_comp__lead_qr_scanner_multi.svelte';
|
||||
import Comp_lead_manual_search from './ae_comp__lead_manual_search.svelte';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__add.svelte
|
||||
* Tab 2: Add - Search / QR Scan Layout.
|
||||
*
|
||||
* Two orthogonal toggles:
|
||||
* - mode: 'qr' | 'search' — how to find the attendee
|
||||
* - scan_qualify: 'rapid' | 'qualify' | 'auto' | 'multi' — what to do after finding (QR mode only)
|
||||
* rapid: confirm tap → auto-reset → scan next person immediately
|
||||
* qualify: confirm tap → navigate to lead detail → fill qualifiers/notes
|
||||
* auto: no confirm — badge is added immediately on scan → auto-reset
|
||||
* multi: BarcodeDetector batch scan → grid of confirm cards
|
||||
*/
|
||||
import { Bot, ChevronDown, Layers, QrCode, Search, Zap } from '@lucide/svelte';
|
||||
import Comp_lead_qr_scanner from './ae_comp__lead_qr_scanner.svelte';
|
||||
import Comp_lead_qr_scanner_multi from './ae_comp__lead_qr_scanner_multi.svelte';
|
||||
import Comp_lead_manual_search from './ae_comp__lead_manual_search.svelte';
|
||||
import { events_loc } from '$lib/stores/ae_events_stores';
|
||||
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
}
|
||||
interface Props {
|
||||
exhibit_id: string;
|
||||
}
|
||||
|
||||
let { exhibit_id }: Props = $props();
|
||||
let { exhibit_id }: Props = $props();
|
||||
|
||||
// QR vs Manual Search (persisted per exhibit)
|
||||
let mode = $derived($events_loc.leads.tab_add_mode?.[exhibit_id] ?? 'qr');
|
||||
// QR vs Manual Search (persisted per exhibit)
|
||||
let mode = $derived($events_loc.leads.tab_add_mode?.[exhibit_id] ?? 'qr');
|
||||
|
||||
function set_mode(new_mode: string) {
|
||||
if (!$events_loc.leads.tab_add_mode) $events_loc.leads.tab_add_mode = {};
|
||||
$events_loc.leads.tab_add_mode[exhibit_id] = new_mode;
|
||||
}
|
||||
function set_mode(new_mode: string) {
|
||||
if (!$events_loc.leads.tab_add_mode) $events_loc.leads.tab_add_mode = {};
|
||||
$events_loc.leads.tab_add_mode[exhibit_id] = new_mode;
|
||||
}
|
||||
|
||||
// Scan qualify mode (persisted per exhibit)
|
||||
// Scan qualify mode (persisted per exhibit)
|
||||
// 'qualify' was merged into 'rapid' — normalize stale localStorage values
|
||||
type ScanQualifyMode = 'rapid' | 'auto' | 'multi';
|
||||
let scan_qualify = $derived.by(() => {
|
||||
const raw = $events_loc.leads.tab_scan_qualify?.[exhibit_id] ?? 'rapid';
|
||||
// 'qualify' was merged into 'rapid' — normalize stale localStorage values
|
||||
type ScanQualifyMode = 'rapid' | 'auto' | 'multi';
|
||||
let scan_qualify = $derived.by(() => {
|
||||
const raw = $events_loc.leads.tab_scan_qualify?.[exhibit_id] ?? 'rapid';
|
||||
// 'qualify' was merged into 'rapid' — normalize stale localStorage values
|
||||
return (raw === 'qualify' ? 'rapid' : raw) as ScanQualifyMode;
|
||||
});
|
||||
return (raw === 'qualify' ? 'rapid' : raw) as ScanQualifyMode;
|
||||
});
|
||||
|
||||
function set_scan_qualify(new_mode: ScanQualifyMode) {
|
||||
if (!$events_loc.leads.tab_scan_qualify) $events_loc.leads.tab_scan_qualify = {};
|
||||
$events_loc.leads.tab_scan_qualify[exhibit_id] = new_mode;
|
||||
show_mode_opts = false;
|
||||
function set_scan_qualify(new_mode: ScanQualifyMode) {
|
||||
if (!$events_loc.leads.tab_scan_qualify)
|
||||
$events_loc.leads.tab_scan_qualify = {};
|
||||
$events_loc.leads.tab_scan_qualify[exhibit_id] = new_mode;
|
||||
show_mode_opts = false;
|
||||
}
|
||||
|
||||
function handle_lead_added(badge: any) {
|
||||
$events_loc.leads.tracking__search_version++;
|
||||
}
|
||||
|
||||
// Mode selector expand/collapse
|
||||
let show_mode_opts = $state(false);
|
||||
|
||||
// Mode config — drives both the trigger display and the options grid
|
||||
const qr_modes: Array<{
|
||||
value: ScanQualifyMode;
|
||||
label: string;
|
||||
desc: string;
|
||||
icon: any;
|
||||
}> = [
|
||||
{
|
||||
value: 'rapid',
|
||||
label: 'Confirm',
|
||||
desc: 'Tap Add & Scan or Add & View',
|
||||
icon: Zap
|
||||
},
|
||||
{
|
||||
value: 'auto',
|
||||
label: 'Auto',
|
||||
desc: 'Auto-add · no tap needed',
|
||||
icon: Bot
|
||||
},
|
||||
{
|
||||
value: 'multi',
|
||||
label: 'Multi',
|
||||
desc: 'Batch scan up to 4 badges',
|
||||
icon: Layers
|
||||
}
|
||||
];
|
||||
|
||||
function handle_lead_added(badge: any) {
|
||||
$events_loc.leads.tracking__search_version++;
|
||||
}
|
||||
|
||||
// Mode selector expand/collapse
|
||||
let show_mode_opts = $state(false);
|
||||
|
||||
// Mode config — drives both the trigger display and the options grid
|
||||
const qr_modes: Array<{
|
||||
value: ScanQualifyMode;
|
||||
label: string;
|
||||
desc: string;
|
||||
icon: any;
|
||||
}> = [
|
||||
{ value: 'rapid', label: 'Confirm', desc: 'Tap Add & Scan or Add & View', icon: Zap },
|
||||
{ value: 'auto', label: 'Auto', desc: 'Auto-add · no tap needed', icon: Bot },
|
||||
{ value: 'multi', label: 'Multi', desc: 'Batch scan up to 4 badges', icon: Layers },
|
||||
];
|
||||
|
||||
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>
|
||||
|
||||
<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 -->
|
||||
<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"
|
||||
onclick={() => set_mode(mode === 'qr' ? 'search' : 'qr')}
|
||||
>
|
||||
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')}>
|
||||
{#if mode === 'qr'}
|
||||
<Search size="1.1em" />
|
||||
<span>Switch to Manual Search</span>
|
||||
@@ -88,60 +104,61 @@
|
||||
<!-- Scan mode selector (QR mode only) -->
|
||||
{#if mode === 'qr'}
|
||||
<div class="w-full">
|
||||
|
||||
<!-- Trigger: shows active mode, tapping expands options -->
|
||||
<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"
|
||||
onclick={() => show_mode_opts = !show_mode_opts}
|
||||
title="Change scan mode"
|
||||
>
|
||||
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)}
|
||||
title="Change scan mode">
|
||||
<!-- 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-tertiary-500={scan_qualify === 'auto'}
|
||||
class:bg-warning-500={scan_qualify === 'multi'}
|
||||
>
|
||||
class:bg-warning-500={scan_qualify === 'multi'}>
|
||||
<active_mode.icon size="1em" />
|
||||
</span>
|
||||
|
||||
<!-- Mode name + description -->
|
||||
<div class="flex-1 text-left">
|
||||
<span class="font-bold text-sm">{active_mode.label}</span>
|
||||
<span class="text-xs opacity-50 ml-2">{active_mode.desc}</span>
|
||||
<span class="text-sm font-bold">{active_mode.label}</span>
|
||||
<span class="ml-2 text-xs opacity-50"
|
||||
>{active_mode.desc}</span>
|
||||
</div>
|
||||
|
||||
<!-- Chevron -->
|
||||
<ChevronDown
|
||||
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>
|
||||
|
||||
<!-- Options grid (2×2) — shown when trigger is tapped -->
|
||||
{#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}
|
||||
<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:opacity-50={scan_qualify !== m.value}
|
||||
class:bg-surface-50-900={scan_qualify === m.value}
|
||||
class:shadow={scan_qualify === m.value}
|
||||
class:ring-1={scan_qualify === m.value}
|
||||
class:ring-surface-500={scan_qualify === m.value}
|
||||
onclick={() => set_scan_qualify(m.value)}
|
||||
>
|
||||
<span class="p-1.5 rounded-lg text-white"
|
||||
onclick={() => set_scan_qualify(m.value)}>
|
||||
<span
|
||||
class="rounded-lg p-1.5 text-white"
|
||||
class:bg-primary-500={m.value === 'rapid'}
|
||||
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" />
|
||||
</span>
|
||||
<span class="font-bold text-sm">{m.label}</span>
|
||||
<span class="text-[10px] opacity-60 leading-tight">{m.desc}</span>
|
||||
<span class="text-sm font-bold">{m.label}</span>
|
||||
<span class="text-[10px] leading-tight opacity-60"
|
||||
>{m.desc}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -150,15 +167,22 @@
|
||||
{/if}
|
||||
|
||||
<!-- 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 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}
|
||||
<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}
|
||||
{: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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__list.svelte
|
||||
* Tab 3: Leads List Stub.
|
||||
*/
|
||||
import Comp_exhibit_tracking_obj_li from './ae_comp__exhibit_tracking_obj_li.svelte';
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__list.svelte
|
||||
* Tab 3: Leads List Stub.
|
||||
*/
|
||||
import Comp_exhibit_tracking_obj_li from './ae_comp__exhibit_tracking_obj_li.svelte';
|
||||
|
||||
interface Props {
|
||||
lq__event_exhibit_tracking_obj_li: any;
|
||||
}
|
||||
let { lq__event_exhibit_tracking_obj_li }: Props = $props();
|
||||
interface Props {
|
||||
lq__event_exhibit_tracking_obj_li: any;
|
||||
}
|
||||
let { lq__event_exhibit_tracking_obj_li }: Props = $props();
|
||||
</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>
|
||||
<Comp_exhibit_tracking_obj_li {lq__event_exhibit_tracking_obj_li} />
|
||||
</div>
|
||||
|
||||
@@ -1,74 +1,106 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__manage.svelte
|
||||
* Tab 4: Manage/Config - Exhibitor Settings and Profile.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
|
||||
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 Comp_exhibit_license_list from './ae_comp__exhibit_license_list.svelte';
|
||||
import Comp_exhibit_custom_questions from './ae_comp__exhibit_custom_questions.svelte';
|
||||
import Comp_exhibit_payment from './ae_comp__exhibit_payment.svelte';
|
||||
import { 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 ?? '');
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__manage.svelte
|
||||
* Tab 4: Manage/Config - Exhibitor Settings and Profile.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
import { events_loc, events_sess } from '$lib/stores/ae_events_stores';
|
||||
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 Comp_exhibit_license_list from './ae_comp__exhibit_license_list.svelte';
|
||||
import Comp_exhibit_custom_questions from './ae_comp__exhibit_custom_questions.svelte';
|
||||
import Comp_exhibit_payment from './ae_comp__exhibit_payment.svelte';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
CreditCard,
|
||||
Database,
|
||||
Info,
|
||||
Key,
|
||||
Lock,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
Store,
|
||||
UserX,
|
||||
Users
|
||||
} from '@lucide/svelte';
|
||||
const exhibit_id = $derived(page.params.exhibit_id ?? '');
|
||||
|
||||
let lq__exhibit_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!exhibit_id) return null;
|
||||
return await db_events.exhibit.get(exhibit_id);
|
||||
})
|
||||
);
|
||||
let lq__exhibit_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!exhibit_id) return null;
|
||||
return await db_events.exhibit.get(exhibit_id);
|
||||
})
|
||||
);
|
||||
|
||||
// Track local status for specific actions
|
||||
let updating = $state(false);
|
||||
let show_license_mgmt = $state(false);
|
||||
let show_custom_questions = $state(false);
|
||||
let show_billing = $state(false);
|
||||
// Track local status for specific actions
|
||||
let updating = $state(false);
|
||||
let show_license_mgmt = $state(false);
|
||||
let show_custom_questions = $state(false);
|
||||
let show_billing = $state(false);
|
||||
|
||||
function handle_signout() {
|
||||
if (confirm('Sign out from this booth?')) {
|
||||
delete $events_loc.leads.auth_exhibit_kv[exhibit_id];
|
||||
$events_sess.leads.entered_passcode = null;
|
||||
// Navigate to start tab
|
||||
if (!$events_loc.leads.tab) $events_loc.leads.tab = {};
|
||||
$events_loc.leads.tab[exhibit_id] = 'start';
|
||||
}
|
||||
function handle_signout() {
|
||||
if (confirm('Sign out from this booth?')) {
|
||||
delete $events_loc.leads.auth_exhibit_kv[exhibit_id];
|
||||
$events_sess.leads.entered_passcode = null;
|
||||
// Navigate to start tab
|
||||
if (!$events_loc.leads.tab) $events_loc.leads.tab = {};
|
||||
$events_loc.leads.tab[exhibit_id] = 'start';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="ae-tab-manage w-full space-y-8 animate-in fade-in slide-in-from-bottom-2 duration-300 pb-20">
|
||||
|
||||
<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) -->
|
||||
{#if $ae_loc.manager_access}
|
||||
<section class="space-y-4 p-4 border-2 border-primary-500/20 rounded-xl bg-primary-500/5">
|
||||
<div class="flex items-center gap-2 border-b border-primary-500/10 pb-2">
|
||||
<section
|
||||
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" />
|
||||
<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 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 -->
|
||||
<div class="card p-3 preset-tonal-surface flex items-center justify-between">
|
||||
<div class="text-[10px] uppercase font-black opacity-40">Payment Status</div>
|
||||
<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
|
||||
object_type="event_exhibit"
|
||||
object_id={exhibit_id}
|
||||
field_name="priority"
|
||||
field_type="checkbox"
|
||||
current_value={$lq__exhibit_obj?.priority}
|
||||
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })}
|
||||
>
|
||||
<div class="font-bold">{$lq__exhibit_obj?.priority ? 'PAID' : 'PENDING'}</div>
|
||||
on_success={() =>
|
||||
events_func.load_ae_obj_id__event_exhibit({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id
|
||||
})}>
|
||||
<div class="font-bold">
|
||||
{$lq__exhibit_obj?.priority ? 'PAID' : 'PENDING'}
|
||||
</div>
|
||||
</Element_ae_obj_field_editor>
|
||||
</div>
|
||||
|
||||
<!-- Max Licenses -->
|
||||
<div class="card p-3 preset-tonal-surface flex items-center justify-between">
|
||||
<div class="text-[10px] uppercase font-black opacity-40">Max Licenses</div>
|
||||
<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
|
||||
object_type="event_exhibit"
|
||||
object_id={exhibit_id}
|
||||
@@ -76,13 +108,19 @@
|
||||
field_type="number"
|
||||
current_value={$lq__exhibit_obj?.license_max}
|
||||
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>
|
||||
|
||||
<!-- Small Devices -->
|
||||
<div class="card p-3 preset-tonal-surface flex items-center justify-between">
|
||||
<div class="text-[10px] uppercase font-black opacity-40">Small Devices</div>
|
||||
<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
|
||||
object_type="event_exhibit"
|
||||
object_id={exhibit_id}
|
||||
@@ -90,13 +128,19 @@
|
||||
field_type="number"
|
||||
current_value={$lq__exhibit_obj?.leads_device_sm_qty}
|
||||
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>
|
||||
|
||||
<!-- Large Devices -->
|
||||
<div class="card p-3 preset-tonal-surface flex items-center justify-between">
|
||||
<div class="text-[10px] uppercase font-black opacity-40">Large Devices</div>
|
||||
<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
|
||||
object_type="event_exhibit"
|
||||
object_id={exhibit_id}
|
||||
@@ -104,8 +148,11 @@
|
||||
field_type="number"
|
||||
current_value={$lq__exhibit_obj?.leads_device_lg_qty}
|
||||
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>
|
||||
</section>
|
||||
@@ -113,16 +160,21 @@
|
||||
|
||||
<!-- Section: Booth Profile -->
|
||||
<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" />
|
||||
<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 class="grid grid-cols-1 gap-6">
|
||||
<!-- 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">
|
||||
<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>
|
||||
<Element_ae_obj_field_editor
|
||||
object_type="event_exhibit"
|
||||
@@ -132,15 +184,23 @@
|
||||
current_value={$lq__exhibit_obj?.name}
|
||||
display_block={true}
|
||||
class_li="font-bold text-xl"
|
||||
on_success={() => events_func.load_ae_obj_id__event_exhibit({ api_cfg: $ae_api, exhibit_id })}
|
||||
/>
|
||||
<p class="text-[10px] opacity-50 mt-2 italic">This name is visible to attendees when you scan their badges.</p>
|
||||
on_success={() =>
|
||||
events_func.load_ae_obj_id__event_exhibit({
|
||||
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>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
<Element_ae_obj_field_editor
|
||||
object_type="event_exhibit"
|
||||
@@ -150,29 +210,39 @@
|
||||
current_value={$lq__exhibit_obj?.description}
|
||||
display_block={true}
|
||||
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>
|
||||
</section>
|
||||
|
||||
<!-- Section: Staff Access -->
|
||||
<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" />
|
||||
<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 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 -->
|
||||
<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-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 -->
|
||||
{#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 || '----'}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -185,33 +255,44 @@
|
||||
current_value={$lq__exhibit_obj?.staff_passcode}
|
||||
display_block={true}
|
||||
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>
|
||||
<Key size="1.5em" class="opacity-20" />
|
||||
</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>
|
||||
|
||||
<!-- 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>
|
||||
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest">Booth Identifier</div>
|
||||
<div class="font-mono text-xl font-bold">#{$lq__exhibit_obj?.code || 'N/A'}</div>
|
||||
<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>
|
||||
<Info size="1.5em" class="opacity-20" />
|
||||
</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>
|
||||
|
||||
<!-- Sign Out -->
|
||||
{#if !$ae_loc.manager_access}
|
||||
<button
|
||||
class="btn preset-outlined-error w-full mt-2"
|
||||
onclick={handle_signout}
|
||||
>
|
||||
class="btn preset-outlined-error mt-2 w-full"
|
||||
onclick={handle_signout}>
|
||||
<LogOut size="1.2em" class="mr-2" /> Sign Out of Booth
|
||||
</button>
|
||||
{/if}
|
||||
@@ -219,43 +300,57 @@
|
||||
|
||||
<!-- Section: Lead Settings -->
|
||||
<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" />
|
||||
<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 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.
|
||||
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 -->
|
||||
{#if $ae_loc.administrator_access || $events_loc.leads.auth_exhibit_kv?.[exhibit_id]?.type === 'shared'}
|
||||
<div class="p-0">
|
||||
<button
|
||||
class="w-full p-4 flex items-center justify-between hover:bg-surface-500/5 transition-colors group"
|
||||
onclick={() => show_license_mgmt = !show_license_mgmt}
|
||||
>
|
||||
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)}>
|
||||
<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="font-bold text-sm">Exhibit Leads Licensees</div>
|
||||
<div class="text-xs opacity-50">Manage assigned users and codes</div>
|
||||
<div class="text-sm font-bold">
|
||||
Exhibit Leads Licensees
|
||||
</div>
|
||||
<div class="text-xs opacity-50">
|
||||
Manage assigned users and codes
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if show_license_mgmt}
|
||||
<ChevronDown size="1.2em" class="opacity-20" />
|
||||
{: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}
|
||||
</button>
|
||||
|
||||
{#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
|
||||
{exhibit_id}
|
||||
event_id={page.params.event_id ?? ''}
|
||||
license_li_json={$lq__exhibit_obj?.license_li_json ?? '[]'}
|
||||
license_max={$lq__exhibit_obj?.license_max}
|
||||
/>
|
||||
license_li_json={$lq__exhibit_obj?.license_li_json ??
|
||||
'[]'}
|
||||
license_max={$lq__exhibit_obj?.license_max} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -264,30 +359,40 @@
|
||||
<!-- Custom Questions -->
|
||||
<div class="p-0">
|
||||
<button
|
||||
class="w-full p-4 flex items-center justify-between hover:bg-surface-500/5 transition-colors group"
|
||||
onclick={() => show_custom_questions = !show_custom_questions}
|
||||
>
|
||||
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)}>
|
||||
<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="font-bold text-sm">Qualifiers & Questions</div>
|
||||
<div class="text-xs opacity-50">Configure lead capture follow-up responses</div>
|
||||
<div class="text-sm font-bold">
|
||||
Qualifiers & Questions
|
||||
</div>
|
||||
<div class="text-xs opacity-50">
|
||||
Configure lead capture follow-up responses
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if show_custom_questions}
|
||||
<ChevronDown size="1.2em" class="opacity-20" />
|
||||
{: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}
|
||||
</button>
|
||||
|
||||
{#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
|
||||
{exhibit_id}
|
||||
event_id={page.params.event_id ?? ''}
|
||||
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ?? '[]'}
|
||||
/>
|
||||
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ??
|
||||
'[]'} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -295,25 +400,34 @@
|
||||
<!-- Billing -->
|
||||
<div class="p-0">
|
||||
<button
|
||||
class="w-full p-4 flex items-center justify-between hover:bg-surface-500/5 transition-colors group"
|
||||
onclick={() => show_billing = !show_billing}
|
||||
>
|
||||
class="hover:bg-surface-500/5 group flex w-full items-center justify-between p-4 transition-colors"
|
||||
onclick={() => (show_billing = !show_billing)}>
|
||||
<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="font-bold text-sm">Licenses & Billing</div>
|
||||
<div class="text-xs opacity-50">Review licenses and manage payment</div>
|
||||
<div class="text-sm font-bold">
|
||||
Licenses & Billing
|
||||
</div>
|
||||
<div class="text-xs opacity-50">
|
||||
Review licenses and manage payment
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if show_billing}
|
||||
<ChevronDown size="1.2em" class="opacity-20" />
|
||||
{: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}
|
||||
</button>
|
||||
|
||||
{#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 />
|
||||
</div>
|
||||
{/if}
|
||||
@@ -323,28 +437,46 @@
|
||||
|
||||
<!-- Section: App Settings -->
|
||||
<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" />
|
||||
<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 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 -->
|
||||
<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">
|
||||
<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>
|
||||
<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 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>
|
||||
<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 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>
|
||||
<input type="checkbox" class="checkbox" bind:checked={$events_loc.show_details} />
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
bind:checked={$events_loc.show_details} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,56 +484,81 @@
|
||||
<!-- List Refresh -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest">Data Synchronization</div>
|
||||
<div class="flex items-center gap-2 text-[10px] font-mono opacity-60">
|
||||
<div
|
||||
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" />
|
||||
{#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}
|
||||
Waiting...
|
||||
{/if}
|
||||
</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">
|
||||
<span class="text-sm block">Refresh Interval (sec)</span>
|
||||
<div class="text-[9px] opacity-40 uppercase font-bold">
|
||||
Next Sync in <span class="text-primary-500">{$events_sess.leads.next_refresh_countdown}s</span>
|
||||
<span class="block text-sm"
|
||||
>Refresh Interval (sec)</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>
|
||||
<input
|
||||
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"
|
||||
max="120"
|
||||
bind:value={$events_loc.leads.refresh_interval_sec}
|
||||
placeholder="25"
|
||||
/>
|
||||
placeholder="25" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance -->
|
||||
<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">
|
||||
<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
|
||||
</button>
|
||||
<button class="btn btn-sm preset-outlined-error" onclick={() => {
|
||||
if(confirm('Clear all local cached data (IDB)?')) {
|
||||
db_events.delete().then(() => window.location.reload());
|
||||
}
|
||||
}}>
|
||||
<button
|
||||
class="btn btn-sm preset-outlined-error"
|
||||
onclick={() => {
|
||||
if (confirm('Clear all local cached data (IDB)?')) {
|
||||
db_events
|
||||
.delete()
|
||||
.then(() => window.location.reload());
|
||||
}
|
||||
}}>
|
||||
<Database size="1em" class="mr-2" /> Clear IDB
|
||||
</button>
|
||||
<button class="btn btn-sm preset-outlined-error col-span-2" onclick={() => {
|
||||
if(confirm('Reset all local app settings and sign out?')) {
|
||||
localStorage.clear();
|
||||
window.location.reload();
|
||||
}
|
||||
}}>
|
||||
<UserX size="1em" class="mr-2" /> Clear Local Settings (Hard Reset)
|
||||
<button
|
||||
class="btn btn-sm preset-outlined-error col-span-2"
|
||||
onclick={() => {
|
||||
if (
|
||||
confirm(
|
||||
'Reset all local app settings and sign out?'
|
||||
)
|
||||
) {
|
||||
localStorage.clear();
|
||||
window.location.reload();
|
||||
}
|
||||
}}>
|
||||
<UserX size="1em" class="mr-2" /> Clear Local Settings (Hard
|
||||
Reset)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -409,13 +566,14 @@
|
||||
</section>
|
||||
|
||||
<!-- 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-[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>
|
||||
|
||||
<style lang="postcss">
|
||||
/* Custom tab styles if needed */
|
||||
/* Custom tab styles if needed */
|
||||
</style>
|
||||
|
||||
@@ -1,69 +1,85 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__start.svelte
|
||||
* Tab 1: Start / Sign In / Welcome.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import Comp_exhibit_signin from './ae_comp__exhibit_signin.svelte';
|
||||
import Element_pwa_install_prompt from '$lib/elements/element_pwa_install_prompt.svelte';
|
||||
import { CircleCheck, LayoutGrid, ShieldCheck, UserCheck } from '@lucide/svelte';
|
||||
const exhibit_id = $derived(page.params.exhibit_id ?? '');
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__start.svelte
|
||||
* Tab 1: Start / Sign In / Welcome.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import Comp_exhibit_signin from './ae_comp__exhibit_signin.svelte';
|
||||
import Element_pwa_install_prompt from '$lib/elements/element_pwa_install_prompt.svelte';
|
||||
import {
|
||||
CircleCheck,
|
||||
LayoutGrid,
|
||||
ShieldCheck,
|
||||
UserCheck
|
||||
} from '@lucide/svelte';
|
||||
const exhibit_id = $derived(page.params.exhibit_id ?? '');
|
||||
|
||||
let lq__exhibit_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!exhibit_id) return null;
|
||||
return await db_events.exhibit.get(exhibit_id);
|
||||
})
|
||||
);
|
||||
let lq__exhibit_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!exhibit_id) return null;
|
||||
return await db_events.exhibit.get(exhibit_id);
|
||||
})
|
||||
);
|
||||
</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 -->
|
||||
<section class="text-center space-y-4 py-6">
|
||||
<div class="inline-flex p-4 rounded-full bg-primary-500/10 text-primary-500 mb-2">
|
||||
<section class="space-y-4 py-6 text-center">
|
||||
<div
|
||||
class="bg-primary-500/10 text-primary-500 mb-2 inline-flex rounded-full p-4">
|
||||
<LayoutGrid size="3em" />
|
||||
</div>
|
||||
<h2 class="text-3xl font-black tracking-tight">
|
||||
Welcome to the<br />
|
||||
<span class="text-primary-500">Exhibitor Portal</span>
|
||||
</h2>
|
||||
<p class="text-lg opacity-60 max-w-md mx-auto">
|
||||
Ready to capture leads for <span class="font-bold text-surface-900-100">{$lq__exhibit_obj?.name || 'this exhibit'}</span>?
|
||||
<p class="mx-auto max-w-md text-lg opacity-60">
|
||||
Ready to capture leads for <span
|
||||
class="text-surface-900-100 font-bold"
|
||||
>{$lq__exhibit_obj?.name || 'this exhibit'}</span
|
||||
>?
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Features Grid (Compact) -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 max-w-2xl mx-auto px-4">
|
||||
<div class="flex flex-col items-center text-center p-4 rounded-xl preset-tonal-surface">
|
||||
<div class="mx-auto grid max-w-2xl grid-cols-1 gap-4 px-4 sm:grid-cols-3">
|
||||
<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" />
|
||||
<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 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" />
|
||||
<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 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" />
|
||||
<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>
|
||||
|
||||
<!-- 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 />
|
||||
</div>
|
||||
|
||||
<!-- Sign In Area -->
|
||||
<div class="w-full max-w-md mx-auto">
|
||||
<div class="mx-auto w-full max-w-md">
|
||||
<Comp_exhibit_signin />
|
||||
</div>
|
||||
|
||||
<!-- Info Footer -->
|
||||
<div class="text-center pt-8 opacity-40">
|
||||
<p class="text-[10px] uppercase font-black tracking-[0.2em]">Powered by Aether Platform</p>
|
||||
<div class="pt-8 text-center opacity-40">
|
||||
<p class="text-[10px] font-black tracking-[0.2em] uppercase">
|
||||
Powered by Aether Platform
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,98 +1,117 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/lead/[exhibit_tracking_id]/+page.svelte
|
||||
* Lead Detail View - Basic Read-Only version.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
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 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';
|
||||
const exhibit_tracking_id = $derived(page.params.exhibit_tracking_id);
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/lead/[exhibit_tracking_id]/+page.svelte
|
||||
* Lead Detail View - Basic Read-Only version.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db_events } from '$lib/ae_events/db_events';
|
||||
import { ae_util } from '$lib/ae_utils/ae_utils';
|
||||
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
|
||||
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 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';
|
||||
const exhibit_tracking_id = $derived(page.params.exhibit_tracking_id);
|
||||
|
||||
let lq__lead_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!exhibit_tracking_id) return null;
|
||||
return await db_events.exhibit_tracking.get(exhibit_tracking_id);
|
||||
})
|
||||
);
|
||||
let lq__lead_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
if (!exhibit_tracking_id) return null;
|
||||
return await db_events.exhibit_tracking.get(exhibit_tracking_id);
|
||||
})
|
||||
);
|
||||
|
||||
let lq__exhibit_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
if (!exhibit_id) return null;
|
||||
return await db_events.exhibit.get(exhibit_id);
|
||||
})
|
||||
);
|
||||
let lq__exhibit_obj = $derived(
|
||||
liveQuery(async () => {
|
||||
const exhibit_id = page.params.exhibit_id;
|
||||
if (!exhibit_id) return null;
|
||||
return await db_events.exhibit.get(exhibit_id);
|
||||
})
|
||||
);
|
||||
|
||||
let is_edit_mode = $state(false);
|
||||
let is_edit_mode = $state(false);
|
||||
|
||||
// Remove / Restore flow.
|
||||
// Two-click confirm for remove: idle → confirm → removing → (navigate back).
|
||||
let remove_status = $state<'idle' | 'confirm' | 'removing' | 'restoring'>('idle');
|
||||
// Remove / Restore flow.
|
||||
// Two-click confirm for remove: idle → confirm → removing → (navigate back).
|
||||
let remove_status = $state<'idle' | 'confirm' | 'removing' | 'restoring'>(
|
||||
'idle'
|
||||
);
|
||||
|
||||
async function remove_lead() {
|
||||
const eid = page.params.exhibit_id ?? '';
|
||||
if (!exhibit_tracking_id || !eid) return;
|
||||
remove_status = 'removing';
|
||||
try {
|
||||
await events_func.update_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: eid,
|
||||
exhibit_tracking_id,
|
||||
data: { enable: false }
|
||||
});
|
||||
// Navigate back to exhibit leads list after removal
|
||||
goto(`/events/${page.params.event_id}/leads/exhibit/${eid}`);
|
||||
} catch {
|
||||
// If update fails, reset so user can try again
|
||||
remove_status = 'idle';
|
||||
}
|
||||
async function remove_lead() {
|
||||
const eid = page.params.exhibit_id ?? '';
|
||||
if (!exhibit_tracking_id || !eid) return;
|
||||
remove_status = 'removing';
|
||||
try {
|
||||
await events_func.update_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: eid,
|
||||
exhibit_tracking_id,
|
||||
data: { enable: false }
|
||||
});
|
||||
// Navigate back to exhibit leads list after removal
|
||||
goto(`/events/${page.params.event_id}/leads/exhibit/${eid}`);
|
||||
} catch {
|
||||
// If update fails, reset so user can try again
|
||||
remove_status = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
async function restore_lead() {
|
||||
const eid = page.params.exhibit_id ?? '';
|
||||
if (!exhibit_tracking_id || !eid) return;
|
||||
remove_status = 'restoring';
|
||||
try {
|
||||
await events_func.update_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: eid,
|
||||
exhibit_tracking_id,
|
||||
data: { enable: true }
|
||||
});
|
||||
remove_status = 'idle';
|
||||
} catch {
|
||||
remove_status = 'idle';
|
||||
}
|
||||
async function restore_lead() {
|
||||
const eid = page.params.exhibit_id ?? '';
|
||||
if (!exhibit_tracking_id || !eid) return;
|
||||
remove_status = 'restoring';
|
||||
try {
|
||||
await events_func.update_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: eid,
|
||||
exhibit_tracking_id,
|
||||
data: { enable: true }
|
||||
});
|
||||
remove_status = 'idle';
|
||||
} catch {
|
||||
remove_status = 'idle';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to format date using Aether utility
|
||||
function format_date(date: any) {
|
||||
if (!date) return '';
|
||||
return ae_util.iso_datetime_formatter(date, 'datetime_iso_12_no_seconds');
|
||||
}
|
||||
// Helper to format date using Aether utility
|
||||
function format_date(date: any) {
|
||||
if (!date) return '';
|
||||
return ae_util.iso_datetime_formatter(date, 'datetime_iso_12_no_seconds');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Lead: {$lq__lead_obj?.event_badge_full_name ?? 'Loading...'}</title>
|
||||
</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 -->
|
||||
<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">
|
||||
<a
|
||||
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" />
|
||||
<span class="hidden sm:inline ml-1">Back</span>
|
||||
<span class="ml-1 hidden sm:inline">Back</span>
|
||||
</a>
|
||||
<h1 class="text-lg font-bold">Lead Profile</h1>
|
||||
</div>
|
||||
@@ -103,8 +122,10 @@
|
||||
class="btn btn-sm"
|
||||
class:preset-filled-primary={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}
|
||||
<Eye size="1.2em" class="mr-1" /> View
|
||||
{:else}
|
||||
@@ -119,23 +140,21 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-filled-error font-bold"
|
||||
onclick={remove_lead}
|
||||
>
|
||||
onclick={remove_lead}>
|
||||
<Trash2 size="1em" />
|
||||
Confirm Remove?
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-outlined-surface opacity-60"
|
||||
onclick={() => remove_status = 'idle'}
|
||||
>Cancel</button>
|
||||
onclick={() => (remove_status = 'idle')}
|
||||
>Cancel</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm preset-outlined-error opacity-70"
|
||||
disabled={remove_status === 'removing'}
|
||||
onclick={() => remove_status = 'confirm'}
|
||||
>
|
||||
onclick={() => (remove_status = 'confirm')}>
|
||||
{#if remove_status === 'removing'}
|
||||
<LoaderCircle size="1em" class="animate-spin" />
|
||||
{:else}
|
||||
@@ -148,7 +167,8 @@
|
||||
{/if}
|
||||
|
||||
{#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" />
|
||||
Priority
|
||||
</span>
|
||||
@@ -156,94 +176,143 @@
|
||||
</div>
|
||||
</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}
|
||||
<div class="flex flex-col items-center justify-center p-20 opacity-50 text-center">
|
||||
<LoaderCircle size="3em" class="animate-spin mb-4 mx-auto" />
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 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 -->
|
||||
<div class="lg:col-span-2 space-y-6">
|
||||
<div class="space-y-6 lg:col-span-2">
|
||||
<!-- 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 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<User size="1.4em" class="text-primary-500 flex-none" />
|
||||
<h2 class="text-2xl font-black leading-tight">
|
||||
{@html $lq__lead_obj.event_badge_full_name || $lq__lead_obj.event_badge_full_name_override || 'Unknown Attendee'}
|
||||
<User
|
||||
size="1.4em"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- Key details — all visible above the fold on mobile -->
|
||||
<div class="space-y-1.5 pl-1">
|
||||
{#if $lq__lead_obj.event_badge_professional_title || $lq__lead_obj.event_badge_professional_title_override}
|
||||
<div class="flex items-center gap-2 text-sm opacity-80">
|
||||
<Briefcase size="1em" class="flex-none opacity-60" />
|
||||
<span>{@html $lq__lead_obj.event_badge_professional_title || $lq__lead_obj.event_badge_professional_title_override}</span>
|
||||
<div
|
||||
class="flex items-center gap-2 text-sm opacity-80">
|
||||
<Briefcase
|
||||
size="1em"
|
||||
class="flex-none opacity-60" />
|
||||
<span
|
||||
>{@html $lq__lead_obj.event_badge_professional_title ||
|
||||
$lq__lead_obj.event_badge_professional_title_override}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if $lq__lead_obj.event_badge_affiliations || $lq__lead_obj.event_badge_affiliations_override}
|
||||
<div class="flex items-center gap-2 text-sm font-semibold text-primary-500">
|
||||
<div
|
||||
class="text-primary-500 flex items-center gap-2 text-sm font-semibold">
|
||||
<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>
|
||||
{/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" />
|
||||
<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 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" />
|
||||
<span>Captured {format_date($lq__lead_obj.created_on)}</span>
|
||||
<span
|
||||
>Captured {format_date(
|
||||
$lq__lead_obj.created_on
|
||||
)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Responses Section -->
|
||||
<div class="card p-6 space-y-4 shadow-md">
|
||||
<div class="flex items-center gap-2 border-b border-surface-500/10 pb-3">
|
||||
<div class="card space-y-4 p-6 shadow-md">
|
||||
<div
|
||||
class="border-surface-500/10 flex items-center gap-2 border-b pb-3">
|
||||
<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>
|
||||
|
||||
{#if is_edit_mode}
|
||||
<Comp_lead_detail_form
|
||||
exhibit_tracking_id={exhibit_tracking_id ?? ''}
|
||||
exhibit_id={page.params.exhibit_id ?? ''}
|
||||
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ?? '[]'}
|
||||
current_responses_json={$lq__lead_obj.responses_json ?? '{}'}
|
||||
/>
|
||||
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ??
|
||||
'[]'}
|
||||
current_responses_json={$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}
|
||||
<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)}
|
||||
{@const display_value = (answer !== null && typeof answer === 'object') ? (answer as any).response ?? '' : String(answer ?? '')}
|
||||
<div class="p-3 bg-surface-500/5 rounded-lg border border-surface-500/10">
|
||||
<div class="text-[10px] uppercase font-black opacity-40 tracking-widest mb-1 leading-tight">{question}</div>
|
||||
<div class="font-semibold text-sm">{display_value || '—'}</div>
|
||||
{@const display_value =
|
||||
answer !== null &&
|
||||
typeof answer === 'object'
|
||||
? ((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>
|
||||
{/each}
|
||||
</div>
|
||||
{: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}
|
||||
{: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}
|
||||
</div>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<div class="card p-6 space-y-4 shadow-md">
|
||||
<div class="flex items-center gap-2 border-b border-surface-500/10 pb-3">
|
||||
<div class="card space-y-4 p-6 shadow-md">
|
||||
<div
|
||||
class="border-surface-500/10 flex items-center gap-2 border-b pb-3">
|
||||
<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 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}
|
||||
<Element_ae_obj_field_editor
|
||||
object_type="event_exhibit_tracking"
|
||||
@@ -252,14 +321,15 @@
|
||||
field_type="tiptap"
|
||||
current_value={$lq__lead_obj.exhibitor_notes}
|
||||
object_reload={true}
|
||||
display_block={true}
|
||||
/>
|
||||
display_block={true} />
|
||||
{: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}
|
||||
</div>
|
||||
{: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.
|
||||
</div>
|
||||
{/if}
|
||||
@@ -270,69 +340,105 @@
|
||||
<!-- Right: Metadata & Stats -->
|
||||
<div class="space-y-6">
|
||||
<!-- exhibit association -->
|
||||
<div class="card p-5 space-y-4 shadow-md bg-surface-100-900 border border-surface-500/10">
|
||||
<div class="flex items-center gap-2 text-primary-500">
|
||||
<div
|
||||
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" />
|
||||
<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 class="space-y-3">
|
||||
{#if is_edit_mode}
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm opacity-60">Exhibit Name</span>
|
||||
<span class="font-bold">{$lq__lead_obj.event_exhibit_name || '...'}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm opacity-60"
|
||||
>Exhibit Name</span>
|
||||
<span class="font-bold"
|
||||
>{$lq__lead_obj.event_exhibit_name ||
|
||||
'...'}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm opacity-60">Captured By</span>
|
||||
<span class="font-mono text-[10px]">{$lq__lead_obj.external_person_id || 'Unknown'}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm opacity-60"
|
||||
>Captured By</span>
|
||||
<span class="font-mono text-[10px]"
|
||||
>{$lq__lead_obj.external_person_id ||
|
||||
'Unknown'}</span>
|
||||
</div>
|
||||
|
||||
{#if is_edit_mode}
|
||||
<div class="flex justify-between items-center pt-2 border-t border-surface-500/10">
|
||||
<span class="text-xs opacity-60 font-bold">Priority Lead</span>
|
||||
<div
|
||||
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
|
||||
object_type="event_exhibit_tracking"
|
||||
object_id={exhibit_tracking_id ?? ''}
|
||||
field_name="priority"
|
||||
field_type="checkbox"
|
||||
current_value={$lq__lead_obj.priority}
|
||||
object_reload={true}
|
||||
/>
|
||||
object_reload={true} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Info -->
|
||||
<div class="card p-5 space-y-4 shadow-inner bg-surface-500/5 text-[10px] font-mono opacity-60">
|
||||
<div class="font-black uppercase tracking-[0.2em] border-b border-surface-500/10 pb-2 mb-2">System Audit</div>
|
||||
<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><span class="opacity-50">LEAD ID:</span> {$lq__lead_obj.event_exhibit_tracking_id}</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>
|
||||
<span class="opacity-50">LEAD ID:</span>
|
||||
{$lq__lead_obj.event_exhibit_tracking_id}
|
||||
</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>
|
||||
|
||||
<!-- 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. -->
|
||||
{#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">
|
||||
<ShieldCheck size="1.4em" class="text-error-500 shrink-0" />
|
||||
<ShieldCheck
|
||||
size="1.4em"
|
||||
class="text-error-500 shrink-0" />
|
||||
<div>
|
||||
<div class="font-bold text-sm">Lead Removed</div>
|
||||
<div class="text-[10px] opacity-60 uppercase font-black">Not visible in leads list</div>
|
||||
<div class="text-sm font-bold">
|
||||
Lead Removed
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] font-black uppercase opacity-60">
|
||||
Not visible in leads list
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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'}
|
||||
onclick={restore_lead}
|
||||
>
|
||||
onclick={restore_lead}>
|
||||
{#if remove_status === 'restoring'}
|
||||
<LoaderCircle size="1em" class="animate-spin" />
|
||||
<LoaderCircle
|
||||
size="1em"
|
||||
class="animate-spin" />
|
||||
Restoring...
|
||||
{:else}
|
||||
<RotateCcw size="1em" />
|
||||
@@ -342,15 +448,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="postcss">
|
||||
.lead-detail-view {
|
||||
/* Ensure we match the theme's background */
|
||||
@apply bg-transparent;
|
||||
}
|
||||
</style>
|
||||
.lead-detail-view {
|
||||
/* Ensure we match the theme's background */
|
||||
@apply bg-transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,153 +1,164 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/ae_comp__lead_detail_form.svelte
|
||||
* Lead Detail Form - Dynamic Custom Questions Editor.
|
||||
*
|
||||
* Question schema (event_exhibit.leads_custom_questions_json):
|
||||
* [{ code, question, type, option_li }]
|
||||
* - code: machine key — used as the property name in responses_json
|
||||
* - question: human-readable label shown to the exhibitor during capture/review
|
||||
* - type: 'text' | 'textarea' | 'toggle' | 'option'
|
||||
* - option_li: array of choices; first element is always '' (blank default)
|
||||
*
|
||||
* Response storage (event_exhibit_tracking.responses_json):
|
||||
* { [code]: { response: <value> } }
|
||||
* e.g. { "giveaway": { "response": "yes" }, "interest_level": { "response": "Hot" } }
|
||||
*
|
||||
* Backward compat: older questions may use `label` instead of `code`/`question`,
|
||||
* and older responses may store scalars directly (not wrapped in {response: ...}).
|
||||
* Both are handled transparently.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import { ae_api } from '$lib/stores/ae_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { CircleCheck, LoaderCircle, Save } from '@lucide/svelte';
|
||||
interface Props {
|
||||
exhibit_tracking_id: string;
|
||||
exhibit_id: string;
|
||||
custom_questions_json?: string; // From event_exhibit
|
||||
current_responses_json?: string; // From event_exhibit_tracking
|
||||
}
|
||||
/**
|
||||
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/lead/[exhibit_tracking_id]/ae_comp__lead_detail_form.svelte
|
||||
* Lead Detail Form - Dynamic Custom Questions Editor.
|
||||
*
|
||||
* Question schema (event_exhibit.leads_custom_questions_json):
|
||||
* [{ code, question, type, option_li }]
|
||||
* - code: machine key — used as the property name in responses_json
|
||||
* - question: human-readable label shown to the exhibitor during capture/review
|
||||
* - type: 'text' | 'textarea' | 'toggle' | 'option'
|
||||
* - option_li: array of choices; first element is always '' (blank default)
|
||||
*
|
||||
* Response storage (event_exhibit_tracking.responses_json):
|
||||
* { [code]: { response: <value> } }
|
||||
* e.g. { "giveaway": { "response": "yes" }, "interest_level": { "response": "Hot" } }
|
||||
*
|
||||
* Backward compat: older questions may use `label` instead of `code`/`question`,
|
||||
* and older responses may store scalars directly (not wrapped in {response: ...}).
|
||||
* Both are handled transparently.
|
||||
*/
|
||||
import { untrack } from 'svelte';
|
||||
import { ae_api } from '$lib/stores/ae_stores';
|
||||
import { events_func } from '$lib/ae_events/ae_events_functions';
|
||||
import { CircleCheck, LoaderCircle, Save } from '@lucide/svelte';
|
||||
interface Props {
|
||||
exhibit_tracking_id: string;
|
||||
exhibit_id: string;
|
||||
custom_questions_json?: string; // From event_exhibit
|
||||
current_responses_json?: string; // From event_exhibit_tracking
|
||||
}
|
||||
|
||||
let { exhibit_tracking_id, 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([]);
|
||||
// flat_responses: keyed by question code, stores scalar values for form binding.
|
||||
// We unwrap the nested {response: value} format on load and re-wrap on save.
|
||||
let flat_responses: Record<string, any> = $state({});
|
||||
let status = $state('idle'); // idle, saving, success
|
||||
let question_defs: any[] = $state([]);
|
||||
// flat_responses: keyed by question code, stores scalar values for form binding.
|
||||
// We unwrap the nested {response: value} format on load and re-wrap on save.
|
||||
let flat_responses: Record<string, any> = $state({});
|
||||
let status = $state('idle'); // idle, saving, success
|
||||
|
||||
$effect(() => {
|
||||
try {
|
||||
const defs = typeof custom_questions_json === 'string'
|
||||
$effect(() => {
|
||||
try {
|
||||
const defs =
|
||||
typeof custom_questions_json === 'string'
|
||||
? JSON.parse(custom_questions_json || '[]')
|
||||
: (custom_questions_json || []);
|
||||
const raw = typeof current_responses_json === 'string'
|
||||
: custom_questions_json || [];
|
||||
const raw =
|
||||
typeof current_responses_json === 'string'
|
||||
? JSON.parse(current_responses_json || '{}')
|
||||
: (current_responses_json || {});
|
||||
: current_responses_json || {};
|
||||
|
||||
untrack(() => {
|
||||
question_defs = defs;
|
||||
// Flatten: unwrap {response: value} → scalar for form binding
|
||||
const flat: Record<string, any> = {};
|
||||
for (const [key, val] of Object.entries(raw)) {
|
||||
if (val !== null && typeof val === 'object' && 'response' in (val as object)) {
|
||||
flat[key] = (val as any).response ?? '';
|
||||
} else {
|
||||
flat[key] = val ?? ''; // legacy scalar
|
||||
}
|
||||
untrack(() => {
|
||||
question_defs = defs;
|
||||
// Flatten: unwrap {response: value} → scalar for form binding
|
||||
const flat: Record<string, any> = {};
|
||||
for (const [key, val] of Object.entries(raw)) {
|
||||
if (
|
||||
val !== null &&
|
||||
typeof val === 'object' &&
|
||||
'response' in (val as object)
|
||||
) {
|
||||
flat[key] = (val as any).response ?? '';
|
||||
} else {
|
||||
flat[key] = val ?? ''; // legacy scalar
|
||||
}
|
||||
flat_responses = flat;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to parse questions/responses', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve the key for a question def (new: q.code, legacy: q.label)
|
||||
function q_key(q: any): string {
|
||||
return q.code || q.label || '';
|
||||
}
|
||||
|
||||
async function handle_save() {
|
||||
if (!exhibit_tracking_id) return;
|
||||
status = 'saving';
|
||||
try {
|
||||
// Re-wrap scalar values back to {response: value} format before saving
|
||||
const nested: Record<string, any> = {};
|
||||
for (const [key, val] of Object.entries(flat_responses)) {
|
||||
nested[key] = { response: val };
|
||||
}
|
||||
await events_func.update_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: exhibit_id,
|
||||
exhibit_tracking_id: exhibit_tracking_id,
|
||||
data: {
|
||||
responses_json: JSON.stringify(nested)
|
||||
}
|
||||
});
|
||||
status = 'success';
|
||||
setTimeout(() => status = 'idle', 2000);
|
||||
} catch (e) {
|
||||
console.error('Failed to update responses', e);
|
||||
status = 'idle';
|
||||
}
|
||||
flat_responses = flat;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to parse questions/responses', e);
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve the key for a question def (new: q.code, legacy: q.label)
|
||||
function q_key(q: any): string {
|
||||
return q.code || q.label || '';
|
||||
}
|
||||
|
||||
async function handle_save() {
|
||||
if (!exhibit_tracking_id) return;
|
||||
status = 'saving';
|
||||
try {
|
||||
// Re-wrap scalar values back to {response: value} format before saving
|
||||
const nested: Record<string, any> = {};
|
||||
for (const [key, val] of Object.entries(flat_responses)) {
|
||||
nested[key] = { response: val };
|
||||
}
|
||||
await events_func.update_ae_obj__exhibit_tracking({
|
||||
api_cfg: $ae_api,
|
||||
exhibit_id: exhibit_id,
|
||||
exhibit_tracking_id: exhibit_tracking_id,
|
||||
data: {
|
||||
responses_json: JSON.stringify(nested)
|
||||
}
|
||||
});
|
||||
status = 'success';
|
||||
setTimeout(() => (status = 'idle'), 2000);
|
||||
} catch (e) {
|
||||
console.error('Failed to update responses', e);
|
||||
status = 'idle';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<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))}
|
||||
{@const key = q_key(q)}
|
||||
{@const display = q.question || q.label || key}
|
||||
<div class="space-y-2">
|
||||
<label class="label">
|
||||
<span class="text-[10px] uppercase font-black opacity-40 tracking-widest ml-1">{display}</span>
|
||||
<span
|
||||
class="ml-1 text-[10px] font-black tracking-widest uppercase opacity-40"
|
||||
>{display}</span>
|
||||
|
||||
{#if q.type === 'textarea'}
|
||||
<textarea
|
||||
bind:value={flat_responses[key]}
|
||||
class="textarea rounded-lg p-3 text-sm"
|
||||
rows="3"
|
||||
placeholder="Type response..."
|
||||
></textarea>
|
||||
|
||||
placeholder="Type response..."></textarea>
|
||||
{: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
|
||||
type="checkbox"
|
||||
bind:checked={flat_responses[key]}
|
||||
class="checkbox"
|
||||
/>
|
||||
<span class="text-sm font-bold">{flat_responses[key] ? 'Yes' : 'No'}</span>
|
||||
class="checkbox" />
|
||||
<span class="text-sm font-bold"
|
||||
>{flat_responses[key] ? 'Yes' : 'No'}</span>
|
||||
</div>
|
||||
|
||||
{:else if q.type === 'option' || q.type === 'select'}
|
||||
<!-- type 'option' is the current schema; 'select' is legacy compat -->
|
||||
<select
|
||||
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)}
|
||||
{#each q.option_li as opt (opt)}
|
||||
<option value={opt}>{opt || '-- Select --'}</option>
|
||||
<option value={opt}
|
||||
>{opt || '-- Select --'}</option>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Legacy: options was a comma-separated string -->
|
||||
<option value="">-- Select Option --</option>
|
||||
{#each (q.options || '').split(',').map((o: string) => o.trim()) as opt (opt)}
|
||||
{#each (q.options || '')
|
||||
.split(',')
|
||||
.map((o: string) => o.trim()) as opt (opt)}
|
||||
<option value={opt}>{opt}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={flat_responses[key]}
|
||||
class="input rounded-lg p-3 text-sm"
|
||||
placeholder="Type response..."
|
||||
/>
|
||||
placeholder="Type response..." />
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
@@ -155,16 +166,17 @@
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
|
||||
<button
|
||||
class="btn preset-filled-primary w-full font-bold shadow-lg"
|
||||
disabled={status === 'saving'}
|
||||
onclick={handle_save}
|
||||
>
|
||||
onclick={handle_save}>
|
||||
{#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'}
|
||||
<CircleCheck size="1.2em" class="mr-2" /> Saved!
|
||||
{:else}
|
||||
|
||||
Reference in New Issue
Block a user