leads: lead detail UX overhaul — notes editor, priority star, profile card cleanup

- Replace admin field editor with direct TipTap + Save Notes button for exhibitor notes;
  show Add Notes button when notes are empty (no dead placeholder)
- Add one-click priority star toggle in header (always visible, no edit mode required)
- Remove Exhibit Context card (exhibitors don't need to see their own booth name)
- Move Captured By into profile card with human-readable labels
  (shared_passcode → "Booth (Shared)", access type codes → Staff/Admin)
- Add location row (city/state + country) to profile card
- Gate Remove button to edit mode only to prevent accidental taps
- Fix button position stability: Edit/View always rightmost (same screen position),
  Remove grows in from left — prevents double-tap accidents
- Add unsaved-changes guard (beforeNavigate) covering both notes and custom question form
- Custom questions form: hide Save when no questions configured, show
  "Configure in Manage Tab" link instead

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-04-06 20:42:55 -04:00
parent 50e83502ff
commit 1e178c14e7
2 changed files with 197 additions and 132 deletions

View File

@@ -4,13 +4,12 @@
* Lead Detail View - Basic Read-Only version.
*/
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { beforeNavigate, 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 AE_Comp_Editor_TipTap from '$lib/elements/element_editor_tiptap.svelte';
import Comp_lead_detail_form from './ae_comp__lead_detail_form.svelte';
import {
@@ -29,9 +28,9 @@ import {
ShieldCheck,
SquarePen,
Star,
Store,
Trash2,
User
User,
UserCheck
} from '@lucide/svelte';
const exhibit_tracking_id = $derived(page.params.exhibit_tracking_id);
@@ -52,6 +51,51 @@ let lq__exhibit_obj = $derived(
let is_edit_mode = $state(false);
// Priority toggle — one-click, saves immediately without entering edit mode.
let priority_saving = $state(false);
async function toggle_priority() {
if (!exhibit_tracking_id || priority_saving) return;
priority_saving = true;
try {
await events_func.update_ae_obj__exhibit_tracking({
api_cfg: $ae_api,
exhibit_id: page.params.exhibit_id ?? '',
exhibit_tracking_id,
data: { priority: !$lq__lead_obj?.priority }
});
} finally {
priority_saving = false;
}
}
// Human-readable label for external_person_id.
// Raw values: 'shared_passcode' (booth shared login), an email (licensed user),
// or an Aether access_type string (trusted/administrator — staff bypass).
function format_captured_by(id: string | null | undefined): string {
if (!id) return 'Unknown';
if (id === 'shared_passcode') return 'Booth (Shared)';
if (id.includes('@')) return id;
const map: Record<string, string> = {
trusted: 'Staff',
administrator: 'Admin',
edit_mode: 'Staff'
};
return map[id] ?? id;
}
// Unsaved-changes guard — warn before navigating away if notes or responses are dirty.
let form_is_dirty = $state(false);
beforeNavigate(({ cancel }) => {
const notes_dirty =
is_edit_mode &&
draft_notes !== ($lq__lead_obj?.exhibitor_notes ?? '');
if (notes_dirty || form_is_dirty) {
if (!confirm('You have unsaved changes. Leave without saving?')) {
cancel();
}
}
});
// Notes editing state
let draft_notes = $state('');
let notes_status = $state<'idle' | 'saving' | 'success' | 'error'>('idle');
@@ -151,6 +195,58 @@ function format_date(date: any) {
<div class="flex items-center gap-2">
{#if $lq__lead_obj}
<!-- Remove — leftmost so it never lands on top of Edit/View when it appears.
Only shown in edit mode; soft-delete only (enable=false), recoverable. -->
{#if is_edit_mode && $lq__lead_obj.enable}
{#if remove_status === 'confirm'}
<button
type="button"
class="btn btn-sm preset-filled-error font-bold"
onclick={remove_lead}>
<Trash2 size="1em" />
Confirm?
</button>
<button
type="button"
class="btn btn-sm preset-outlined-surface"
onclick={() => (remove_status = 'idle')}>
Cancel
</button>
{:else}
<button
type="button"
class="btn btn-sm preset-outlined-error"
disabled={remove_status === 'removing'}
onclick={() => (remove_status = 'confirm')}>
{#if remove_status === 'removing'}
<LoaderCircle size="1em" class="animate-spin" />
{:else}
<Trash2 size="1em" />
{/if}
<span class="hidden sm:inline">Remove</span>
</button>
{/if}
{/if}
<!-- Priority toggle — always visible, one-click, no edit mode needed. -->
<button
type="button"
class="btn btn-sm font-bold transition-all"
class:preset-filled-warning={$lq__lead_obj.priority}
class:preset-outlined-surface={!$lq__lead_obj.priority}
class:opacity-40={!$lq__lead_obj.priority}
disabled={priority_saving}
onclick={toggle_priority}
title={$lq__lead_obj.priority ? 'Remove priority flag' : 'Mark as priority lead'}>
{#if priority_saving}
<LoaderCircle size="1em" class="animate-spin" />
{:else}
<Star size="1em" class={$lq__lead_obj.priority ? 'fill-current' : ''} />
{/if}
<span class="hidden sm:inline">Priority</span>
</button>
<!-- Edit/View — always rightmost so its position never shifts. -->
<button
class="btn btn-sm"
class:preset-filled-primary={is_edit_mode}
@@ -160,51 +256,11 @@ function format_date(date: any) {
remove_status = 'idle';
}}>
{#if is_edit_mode}
<Eye size="1.2em" class="mr-1" /> View
<Eye size="1.2em" /> <span class="hidden sm:inline ml-1">View</span>
{:else}
<SquarePen size="1.2em" class="mr-1" /> Edit
<SquarePen size="1.2em" /> <span class="hidden sm:inline ml-1">Edit</span>
{/if}
</button>
<!-- Remove Lead — two-click confirm to prevent accidental removal.
Removing only sets enable=false (soft-delete); the record can be restored. -->
{#if $lq__lead_obj.enable}
{#if remove_status === 'confirm'}
<button
type="button"
class="btn btn-sm preset-filled-error font-bold"
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>
{:else}
<button
type="button"
class="btn btn-sm preset-outlined-error opacity-70"
disabled={remove_status === 'removing'}
onclick={() => (remove_status = 'confirm')}>
{#if remove_status === 'removing'}
<LoaderCircle size="1em" class="animate-spin" />
{:else}
<Trash2 size="1em" />
{/if}
Remove
</button>
{/if}
{/if}
{/if}
{#if $lq__lead_obj?.priority}
<span
class="badge preset-filled-warning flex items-center gap-1 font-bold">
<Star size="1em" class="mr-1" />
Priority
</span>
{/if}
</div>
</header>
@@ -258,6 +314,15 @@ function format_date(date: any) {
$lq__lead_obj.event_badge_affiliations_override}</span>
</div>
{/if}
{#if $lq__lead_obj.event_badge_location_override || $lq__lead_obj.event_badge_location || $lq__lead_obj.event_badge_country}
<div class="flex items-center gap-2 text-sm opacity-70">
<MapPin size="1em" class="flex-none opacity-60" />
<span>{[
$lq__lead_obj.event_badge_location_override || $lq__lead_obj.event_badge_location,
$lq__lead_obj.event_badge_country
].filter(Boolean).join(', ')}</span>
</div>
{/if}
<div
class="flex items-center gap-2 text-sm opacity-70">
<a href={`mailto:${$lq__lead_obj.event_badge_email}`}
@@ -282,6 +347,11 @@ function format_date(date: any) {
)}</span>
{/if}
</div>
<div
class="flex items-center gap-2 text-sm opacity-60">
<UserCheck size="1em" class="flex-none" />
<span>Captured by {format_captured_by($lq__lead_obj.external_person_id)}</span>
</div>
</div>
</div>
@@ -300,10 +370,12 @@ function format_date(date: any) {
<Comp_lead_detail_form
exhibit_tracking_id={exhibit_tracking_id ?? ''}
exhibit_id={page.params.exhibit_id ?? ''}
event_id={page.params.event_id ?? ''}
custom_questions_json={$lq__exhibit_obj?.leads_custom_questions_json ??
'[]'}
current_responses_json={$lq__lead_obj.responses_json ??
'{}'} />
'{}'}
bind:is_dirty={form_is_dirty} />
{:else if $lq__lead_obj.responses_json}
{@const responses =
typeof $lq__lead_obj.responses_json === 'string'
@@ -412,77 +484,38 @@ function format_date(date: any) {
<!-- Right: Metadata & Stats -->
<div class="space-y-6">
<!-- exhibit association -->
<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="text-xs font-bold tracking-widest uppercase">
Exhibit Context
</h3>
</div>
<div class="space-y-3">
{#if is_edit_mode}
<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 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="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} />
</div>
{/if}
</div>
</div>
<!-- System Info -->
<div
class="card bg-surface-500/5 space-y-4 p-5 font-mono text-[10px] opacity-60 shadow-inner">
<!-- System Audit — staff/admin only; end users don't need raw IDs. -->
{#if $ae_loc.manager_access}
<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}
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>
<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 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">EXHIBIT:</span>
{$lq__lead_obj.event_exhibit_name || '—'}
</div>
<div>
<span class="opacity-50">MODIFIED:</span>
{format_date($lq__lead_obj.updated_on)}
</div>
</div>
</div>
</div>
{/if}
<!-- 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. -->

View File

@@ -19,21 +19,27 @@
* Both are handled transparently.
*/
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
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';
import { leads_loc } from '$lib/stores/ae_events_stores__leads.svelte';
import { ArrowRight, CircleCheck, LoaderCircle, Save, Settings } from '@lucide/svelte';
interface Props {
exhibit_tracking_id: string;
exhibit_id: string;
event_id: string; // For navigate-to-manage link
custom_questions_json?: string; // From event_exhibit
current_responses_json?: string; // From event_exhibit_tracking
is_dirty?: boolean; // Bindable — parent reads this for unsaved-changes guard
}
let {
exhibit_tracking_id,
exhibit_id,
event_id,
custom_questions_json = '[]',
current_responses_json = '{}'
current_responses_json = '{}',
is_dirty = $bindable(false)
}: Props = $props();
let question_defs: any[] = $state([]);
@@ -42,6 +48,9 @@ let question_defs: any[] = $state([]);
let flat_responses: Record<string, any> = $state({});
let status = $state('idle'); // idle, saving, success
// Snapshot of responses as last saved (or as loaded). Used for dirty detection.
let saved_snapshot = $state('{}');
$effect(() => {
try {
const defs =
@@ -69,12 +78,18 @@ $effect(() => {
}
}
flat_responses = flat;
saved_snapshot = JSON.stringify(flat);
});
} catch (e) {
console.error('Failed to parse questions/responses', e);
}
});
// Expose dirty state to parent so the unsaved-changes guard can check it.
$effect(() => {
is_dirty = JSON.stringify(flat_responses) !== saved_snapshot;
});
// Resolve the key for a question def (new: q.code, legacy: q.label)
function q_key(q: any): string {
return q.code || q.label || '';
@@ -97,6 +112,7 @@ async function handle_save() {
responses_json: JSON.stringify(nested)
}
});
saved_snapshot = JSON.stringify(flat_responses); // mark clean
status = 'success';
setTimeout(() => (status = 'idle'), 2000);
} catch (e) {
@@ -166,21 +182,37 @@ async function handle_save() {
</div>
{#if question_defs.length === 0}
<p class="py-4 text-center italic opacity-30">
No custom questions configured for this exhibit.
</p>
<!-- No questions set up yet — prompt them to configure rather than leaving a dead form. -->
<div
class="preset-tonal-surface border-surface-500/20 flex flex-col items-center gap-3 rounded-xl border p-6 text-center">
<p class="text-sm font-semibold opacity-60">
No custom questions configured for this exhibit yet.
</p>
<button
type="button"
class="btn btn-sm preset-outlined-primary"
onclick={() => {
// Set the tab before navigating so the user lands directly in Manage.
leads_loc.current.tab[exhibit_id] = 'manage';
goto(`/events/${event_id}/leads/exhibit/${exhibit_id}`);
}}>
<Settings size="1.1em" class="mr-1" />
Configure in Manage Tab
<ArrowRight size="1.1em" class="ml-1" />
</button>
</div>
{:else}
<button
class="btn preset-filled-primary w-full font-bold shadow-lg"
disabled={status === 'saving'}
onclick={handle_save}>
{#if status === 'saving'}
<LoaderCircle size="1.2em" class="mr-2 animate-spin" /> Saving...
{:else if status === 'success'}
<CircleCheck size="1.2em" class="mr-2" /> Saved!
{:else}
<Save size="1.2em" class="mr-2" /> Save Responses
{/if}
</button>
{/if}
<button
class="btn preset-filled-primary w-full font-bold shadow-lg"
disabled={status === 'saving'}
onclick={handle_save}>
{#if status === 'saving'}
<LoaderCircle size="1.2em" class="mr-2 animate-spin" /> Saving...
{:else if status === 'success'}
<CircleCheck size="1.2em" class="mr-2" /> Saved!
{:else}
<Save size="1.2em" class="mr-2" /> Save Responses
{/if}
</button>
</div>