leads: UX improvements — manage tab, sign-in flow, notes editor, filter

- leads_api_access toggle in Admin Tools (manager only)
- Account Status section for end users (payment/licenses/API badges + CSV export button)
- Sign-out fix: use Object.fromEntries instead of delete on PersistedState proxy
- Shared passcode sign-in redirects directly to Manage tab (their role is config, not capture)
- Manage tab section reorder: Account Status → Lead Retrieval Config → Booth Profile → Access & Security → App Settings
- Filter dropdown: replace abstract "My Leads" with direct identity options (All / Booth (Shared) / per-licensee); auto-resolves and migrates stale 'my' values
- Lead detail: replace Element_ae_obj_field_editor notes with direct TipTap editor + Save Notes button; Add Notes button on empty state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-06 19:25:38 -04:00
parent 10e9206ca4
commit 50e83502ff
7 changed files with 374 additions and 202 deletions

View File

@@ -187,7 +187,7 @@ function toggle_edit() {
class="badge {display_value
? 'variant-filled-success'
: 'variant-soft-surface'}">
{display_value ? 'Enabled' : 'Disabled'}
{display_value ? 'True' : 'False'}
</span>
{:else if field_type === 'tiptap'}
<div class="prose dark:prose-invert max-w-none">
@@ -260,7 +260,7 @@ function toggle_edit() {
type="checkbox"
bind:checked={draft_value}
class="checkbox" />
<span>{draft_value ? 'Enabled' : 'Disabled'}</span>
<span>{draft_value ? 'True' : 'False'}</span>
</label>
{:else if field_type === 'tiptap'}
<AE_Comp_Editor_TipTap

View File

@@ -1,11 +1,8 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { untrack } from 'svelte';
import { liveQuery } from 'dexie';
import { db_events } from '$lib/ae_events/db_events';
import {
events_sess,
events_slct
} from '$lib/stores/ae_events_stores';
import { events_sess } from '$lib/stores/ae_events_stores';
import { leads_loc } from '$lib/stores/ae_events_stores__leads.svelte';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { page } from '$app/state';
@@ -14,7 +11,6 @@ import { ae_util } from '$lib/ae_utils/ae_utils';
import {
CreditCard,
Download,
LayoutGrid,
List as ListIcon,
LoaderCircle,
Plus,
@@ -148,9 +144,9 @@ let stripe_cfg = $derived({
let search_params = $derived.by(() => {
let licensee_email = leads_loc.current.tracking__qry__licensee_email;
// Resolve "My Leads" to the correct identity used when storing leads.
// Shared-passcode users store 'shared_passcode' literal (not the passcode string itself).
// Licensed users store their email. Aether bypass users store $ae_loc.access_type.
// 'my' is a legacy value — the search component now resolves to the real identity
// on mount and migrates stale persisted values. This block is a silent fallback
// for any session that still has 'my' before the component has had a chance to run.
if (licensee_email === 'my') {
const kv = leads_loc.current.auth_exhibit_kv?.[page.params.exhibit_id ?? ''];
licensee_email = kv?.type === 'shared'

View File

@@ -108,13 +108,12 @@ function complete_signin(key: string, type: string) {
updated_on: new Date().toISOString()
};
// Also update session passcode if shared mode
if (type === 'shared') {
$events_sess.leads.entered_passcode = key;
// Shared passcode users land on Manage — their purpose is configuring
// lead retrieval (licenses, custom questions), not capturing leads.
leads_loc.current.tab[exhibit_id] = 'manage';
}
// Trigger a reload or UI update if needed
// (The parent +page.svelte should reactively update is_signed_in)
}
</script>

View File

@@ -126,9 +126,8 @@ function fuzzy_time_ago(date_str: string) {
{/if}
<div
class="flex items-center gap-1"
title={format_date_full(
event_tracking_obj.created_on
)}>
title={`Added lead:\n${format_date_full(event_tracking_obj.created_on)}`}
>
<Clock size="1em" />
{fuzzy_time_ago(
event_tracking_obj.created_on

View File

@@ -62,17 +62,22 @@ let licensee_li = $derived.by(() => {
}
});
// Default selection logic: Aether Admins go to "all", Licensees go to "my"
// Default selection: resolve to the real identity on first load.
// Also migrates any stale 'my' values persisted from older sessions.
// Identity map: shared passcode → 'shared_passcode'; licensed → their email; admin bypass → 'all'.
$effect(() => {
// Wait for object to load and check if initialized
if (!exhibit_obj) return;
untrack(() => {
if (
leads_loc.current.tracking__qry__licensee_email === 'all' &&
!$ae_loc.administrator_access
) {
leads_loc.current.tracking__qry__licensee_email = 'my';
const cur = leads_loc.current.tracking__qry__licensee_email;
// Act on 'all' (first load) OR stale 'my' (persisted from before this change)
if ((cur === 'all' || cur === 'my') && !$ae_loc.administrator_access) {
const kv = leads_loc.current.auth_exhibit_kv?.[exhibit_id];
if (kv?.type === 'shared') {
leads_loc.current.tracking__qry__licensee_email = 'shared_passcode';
} else if (kv?.type === 'licensed' && kv.key) {
leads_loc.current.tracking__qry__licensee_email = kv.key;
}
// No kv entry means admin/manager bypass — leave as 'all'
}
});
});
@@ -129,9 +134,9 @@ function prevent_default<T extends Event>(fn: (event: T) => void) {
onchange={handle_search_trigger}
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>
{/if}
<!-- Shared passcode leads are always a distinct bucket — anyone who signed
in with the booth passcode (not a personal license) lands here. -->
<option value="shared_passcode">Booth (Shared)</option>
{#each licensee_li as l (l.email)}
<option value={l.email}>{l.full_name || l.email}</option>
{/each}

View File

@@ -21,6 +21,7 @@ import {
Clock,
CreditCard,
Database,
Download,
Info,
Key,
Lock,
@@ -67,9 +68,13 @@ let desc_expanded = $state(false);
function handle_signout() {
if (confirm('Sign out from this booth?')) {
delete leads_loc.current.auth_exhibit_kv[exhibit_id];
// Use object spread instead of delete — `delete` on a PersistedState proxy
// does not reliably trigger Svelte 5 reactivity, so is_signed_in in the parent
// page would never re-evaluate and the sign-in gate would stay open.
leads_loc.current.auth_exhibit_kv = Object.fromEntries(
Object.entries(leads_loc.current.auth_exhibit_kv).filter(([k]) => k !== exhibit_id)
);
$events_sess.leads.entered_passcode = null;
// Navigate to start tab
leads_loc.current.tab[exhibit_id] = 'start';
}
}
@@ -173,165 +178,105 @@ function handle_signout() {
exhibit_id
})} />
</div>
<!-- API / Export Access — gates the CSV export button and backend export endpoint -->
<div
class="card preset-tonal-surface flex items-center justify-between p-3">
<div class="text-[10px] font-black uppercase opacity-40">
API / Export Access
</div>
<Element_ae_obj_field_editor
object_type="event_exhibit"
object_id={exhibit_id}
field_name="leads_api_access"
field_type="checkbox"
current_value={$lq__exhibit_obj?.leads_api_access}
on_success={() =>
events_func.load_ae_obj_id__event_exhibit({
api_cfg: $ae_api,
exhibit_id
})}>
<div class="font-bold">
{$lq__exhibit_obj?.leads_api_access ? 'ON' : 'OFF'}
</div>
</Element_ae_obj_field_editor>
</div>
</div>
</section>
{/if}
<!-- Section: Booth Profile -->
<section class="space-y-4">
<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 tracking-wider uppercase">
Booth Profile
</h3>
</div>
<div class="grid grid-cols-1 gap-6">
<!-- Name -->
<div class="card preset-tonal-surface p-4 shadow-sm">
<div class="label mb-2">
<span
class="text-xs font-black tracking-widest uppercase opacity-40"
>Exhibitor Name</span>
</div>
<Element_ae_obj_field_editor
object_type="event_exhibit"
object_id={exhibit_id}
field_name="name"
field_type="text"
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="mt-2 text-[10px] italic opacity-50">
This name is visible to attendees when you scan their
badges.
</p>
<!-- Section: Account Status (read-only for non-admin signed-in users) -->
<!-- Admins see the editable Admin Tools section above; this is for exhibitor staff. -->
{#if !$ae_loc.manager_access}
<section class="space-y-3">
<div
class="border-surface-500/10 flex items-center gap-2 border-b pb-2">
<Info size="1.2em" class="text-tertiary-500" />
<h3 class="text-lg font-bold tracking-wider uppercase">
Account Status
</h3>
</div>
<!-- Description — collapsed by default (can be long) -->
<div class="card preset-tonal-surface p-4 shadow-sm">
<div class="grid grid-cols-3 gap-3">
<!-- Payment / Priority -->
<div
class="card preset-tonal-surface flex flex-col items-center justify-center gap-1 p-3 text-center">
<div
class="text-[9px] font-black tracking-widest uppercase opacity-40">
Payment
</div>
<div
class="text-sm font-bold"
class:text-success-500={$lq__exhibit_obj?.priority}
class:text-warning-500={!$lq__exhibit_obj?.priority}>
{$lq__exhibit_obj?.priority ? 'PAID' : 'PENDING'}
</div>
</div>
<!-- Max Licenses -->
<div
class="card preset-tonal-surface flex flex-col items-center justify-center gap-1 p-3 text-center">
<div
class="text-[9px] font-black tracking-widest uppercase opacity-40">
Licenses
</div>
<div class="font-mono text-sm font-bold">
{$lq__exhibit_obj?.license_max ?? '—'}
</div>
</div>
<!-- API / Export Access -->
<div
class="card preset-tonal-surface flex flex-col items-center justify-center gap-1 p-3 text-center">
<div
class="text-[9px] font-black tracking-widest uppercase opacity-40">
Export
</div>
<div
class="text-sm font-bold"
class:text-success-500={$lq__exhibit_obj?.leads_api_access}
class:opacity-40={!$lq__exhibit_obj?.leads_api_access}>
{$lq__exhibit_obj?.leads_api_access ? 'ON' : 'OFF'}
</div>
</div>
</div>
<!-- Export button — only when API access is enabled for this exhibit -->
{#if $lq__exhibit_obj?.leads_api_access}
<button
type="button"
class="focus-visible:ring-primary-500 flex w-full items-center justify-between gap-2 rounded text-left focus-visible:ring-2"
onclick={() => (desc_expanded = !desc_expanded)}
aria-expanded={desc_expanded}>
<span class="text-xs font-black tracking-widest uppercase opacity-40"
>Booth Description / Promo</span>
<span class="shrink-0 text-xs opacity-40">
{#if desc_expanded}
<ChevronUp size="1em" />
{:else}
<ChevronDown size="1em" />
{/if}
</span>
class="btn btn-sm preset-outlined-secondary w-full"
onclick={() =>
events_func.download_export__event_exhibit_tracking({
api_cfg: $ae_api,
exhibit_id,
log_lvl: 1
})}>
<Download size="1.2em" class="mr-2" /> Export Leads (CSV)
</button>
{#if desc_expanded}
<div class="mt-3">
<Element_ae_obj_field_editor
object_type="event_exhibit"
object_id={exhibit_id}
field_name="description"
field_type="tiptap"
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
})} />
</div>
{/if}
</div>
</div>
</section>
<!-- Section: Staff Access -->
<section class="space-y-4">
<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 tracking-wider uppercase">
Access & Security
</h3>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Staff Passcode -->
<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="mb-1 text-[10px] font-black tracking-widest uppercase opacity-40">
Staff Passcode
</div>
<Element_ae_obj_field_editor
object_type="event_exhibit"
object_id={exhibit_id}
field_name="staff_passcode"
field_type="text"
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
})} />
</div>
<Key size="1.5em" class="opacity-20" />
</div>
<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 bg-surface-500/5 border-surface-500/10 border p-4">
<div class="flex items-center justify-between">
<div class="flex-1">
<div
class="mb-1 text-[10px] font-black tracking-widest uppercase opacity-40">
Booth Identifier
</div>
<Element_ae_obj_field_editor
object_type="event_exhibit"
object_id={exhibit_id}
field_name="code"
field_type="text"
current_value={$lq__exhibit_obj?.code}
display_block={true}
class_li="font-mono text-xl font-bold"
on_success={() =>
events_func.load_ae_obj_id__event_exhibit({
api_cfg: $ae_api,
exhibit_id
})} />
</div>
<Info size="1.5em" class="opacity-20" />
</div>
<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 mt-2 w-full"
onclick={handle_signout}>
<LogOut size="1.2em" class="mr-2" /> Sign Out of Booth
</button>
{/if}
</section>
{/if}
</section>
{/if}
<!-- Section: Lead Settings -->
<section class="space-y-4">
@@ -472,6 +417,161 @@ function handle_signout() {
</div>
</section>
<!-- Section: Booth Profile -->
<section class="space-y-4">
<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 tracking-wider uppercase">
Booth Profile
</h3>
</div>
<div class="grid grid-cols-1 gap-6">
<!-- Name -->
<div class="card preset-tonal-surface p-4 shadow-sm">
<div class="label mb-2">
<span
class="text-xs font-black tracking-widest uppercase opacity-40"
>Exhibitor Name</span>
</div>
<Element_ae_obj_field_editor
object_type="event_exhibit"
object_id={exhibit_id}
field_name="name"
field_type="text"
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="mt-2 text-[10px] italic opacity-50">
This name should match your booth name.
</p>
</div>
<!-- Description — collapsed by default (can be long) -->
<div class="card preset-tonal-surface p-4 shadow-sm">
<button
type="button"
class="focus-visible:ring-primary-500 flex w-full items-center justify-between gap-2 rounded text-left focus-visible:ring-2"
onclick={() => (desc_expanded = !desc_expanded)}
aria-expanded={desc_expanded}>
<span class="text-xs font-black tracking-widest uppercase opacity-40"
>Booth Description / Promo</span>
<span class="shrink-0 text-xs opacity-40">
{#if desc_expanded}
<ChevronUp size="1em" />
{:else}
<ChevronDown size="1em" />
{/if}
</span>
</button>
{#if desc_expanded}
<div class="mt-3">
<Element_ae_obj_field_editor
object_type="event_exhibit"
object_id={exhibit_id}
field_name="description"
field_type="tiptap"
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
})} />
</div>
{/if}
</div>
</div>
</section>
<!-- Section: Staff Access -->
<section class="space-y-4">
<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 tracking-wider uppercase">
Access & Security
</h3>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<!-- Staff Passcode -->
<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="mb-1 text-[10px] font-black tracking-widest uppercase opacity-40">
Staff Passcode
</div>
<Element_ae_obj_field_editor
object_type="event_exhibit"
object_id={exhibit_id}
field_name="staff_passcode"
field_type="text"
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
})} />
</div>
<Key size="1.5em" class="opacity-20" />
</div>
<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 bg-surface-500/5 border-surface-500/10 border p-4">
<div class="flex items-center justify-between">
<div class="flex-1">
<div
class="mb-1 text-[10px] font-black tracking-widest uppercase opacity-40">
Booth Identifier
</div>
<Element_ae_obj_field_editor
object_type="event_exhibit"
object_id={exhibit_id}
field_name="code"
field_type="text"
current_value={$lq__exhibit_obj?.code}
display_block={true}
class_li="font-mono text-xl font-bold"
on_success={() =>
events_func.load_ae_obj_id__event_exhibit({
api_cfg: $ae_api,
exhibit_id
})} />
</div>
<Info size="1.5em" class="opacity-20" />
</div>
<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 mt-2 w-full"
onclick={handle_signout}>
<LogOut size="1.2em" class="mr-2" /> Sign Out of Booth
</button>
{/if}
</section>
<!-- Section: App Settings -->
<section class="space-y-4">
<div

View File

@@ -11,10 +11,12 @@ 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 AE_Comp_Editor_TipTap from '$lib/elements/element_editor_tiptap.svelte';
import Comp_lead_detail_form from './ae_comp__lead_detail_form.svelte';
import {
Briefcase,
CalendarDays,
Check,
ChevronLeft,
Eye,
FileText,
@@ -23,6 +25,7 @@ import {
Mail,
MapPin,
RotateCcw,
Save,
ShieldCheck,
SquarePen,
Star,
@@ -49,6 +52,36 @@ let lq__exhibit_obj = $derived(
let is_edit_mode = $state(false);
// Notes editing state
let draft_notes = $state('');
let notes_status = $state<'idle' | 'saving' | 'success' | 'error'>('idle');
// Keep draft in sync when viewing (not editing), so it's ready when edit mode opens.
$effect(() => {
if (!is_edit_mode) {
draft_notes = $lq__lead_obj?.exhibitor_notes ?? '';
}
});
async function save_notes() {
if (!exhibit_tracking_id) return;
notes_status = 'saving';
try {
await events_func.update_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: page.params.exhibit_id ?? '',
exhibit_tracking_id,
data: { exhibitor_notes: draft_notes }
});
notes_status = 'success';
setTimeout(() => {
if (notes_status === 'success') notes_status = 'idle';
}, 2000);
} catch {
notes_status = 'error';
}
}
// Remove / Restore flow.
// Two-click confirm for remove: idle → confirm → removing → (navigate back).
let remove_status = $state<'idle' | 'confirm' | 'removing' | 'restoring'>(
@@ -227,18 +260,27 @@ function format_date(date: any) {
{/if}
<div
class="flex items-center gap-2 text-sm opacity-70">
<a href={`mailto:${$lq__lead_obj.event_badge_email}`}
class="flex items-center gap-1 underline">
<Mail size="1em" class="flex-none" />
<span class="truncate font-mono"
>{$lq__lead_obj.event_badge_email ||
'No email on file'}</span>
</a>
</div>
<div
class="flex items-center gap-2 text-sm opacity-60">
<CalendarDays size="1em" class="flex-none" />
<span
>Captured {format_date(
$lq__lead_obj.created_on
>Captured {ae_util.iso_datetime_formatter(
$lq__lead_obj.created_on, 'datetime_12_long'
)}</span>
{#if $lq__lead_obj.updated_on}
<span
>Updated {ae_util.iso_datetime_formatter(
$lq__lead_obj.updated_on, 'datetime_12_long'
)}</span>
{/if}
</div>
</div>
</div>
@@ -311,29 +353,60 @@ function format_date(date: any) {
Exhibitor Notes
</h3>
</div>
<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"
object_id={exhibit_tracking_id ?? ''}
field_name="exhibitor_notes"
field_type="tiptap"
current_value={$lq__lead_obj.exhibitor_notes}
object_reload={true}
display_block={true} />
{:else if $lq__lead_obj.exhibitor_notes}
{#if is_edit_mode}
<AE_Comp_Editor_TipTap
bind:content={draft_notes}
placeholder="Add notes about this lead..." />
<div class="flex items-center justify-end gap-3">
{#if notes_status === 'success'}
<span
class="text-success-500 flex items-center gap-1 text-sm">
<Check size="1em" /> Saved
</span>
{:else if notes_status === 'error'}
<span class="text-error-500 text-sm"
>Save failed — try again</span>
{/if}
<button
type="button"
class="btn btn-sm preset-filled-primary"
disabled={notes_status === 'saving' ||
draft_notes ===
($lq__lead_obj?.exhibitor_notes ??
'')}
onclick={save_notes}>
{#if notes_status === 'saving'}
<LoaderCircle
size="1em"
class="animate-spin" />
{:else}
<Save size="1em" />
{/if}
Save Notes
</button>
</div>
{:else if $lq__lead_obj.exhibitor_notes}
<div
class="bg-surface-500/5 border-surface-500/10 rounded-xl border p-5">
<div
class="prose dark:prose-invert max-w-none leading-relaxed">
{@html $lq__lead_obj.exhibitor_notes}
</div>
{:else}
<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}
</div>
</div>
{:else}
<div
class="bg-surface-500/5 border-surface-500/10 flex min-h-30 items-center justify-center rounded-xl border">
<button
type="button"
class="btn btn-sm preset-outlined-surface"
onclick={() => {
is_edit_mode = true;
remove_status = 'idle';
}}>
<SquarePen size="1.2em" class="mr-1" /> Add Notes
</button>
</div>
{/if}
</div>
</div>