Complete Exhibitor Leads Authentication and Portal Structure

- Implemented dual-mode Sign-In (Shared Passcode / Licensed User) with persistent state.
- Added Manager Bypass logic for seamless admin access.
- Implemented Welcome/Start tab with redirection to Lead List upon authentication.
- Added Sign-Out functionality and Header navigation improvements.
- Integrated all Manage tab sub-sections (Licenses, Questions, Payment).
This commit is contained in:
Scott Idem
2026-02-08 19:23:26 -05:00
parent 72b0086efa
commit b3114c619a
3 changed files with 328 additions and 45 deletions

View File

@@ -19,7 +19,8 @@
List as ListIcon,
LogIn,
LayoutGrid,
Search
Search,
LogOut
} 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';
@@ -45,8 +46,22 @@
$events_loc.leads.refresh_interval_sec = 25;
}
// --- 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($events_loc.leads.tab?.[page.params.exhibit_id ?? ''] ?? 'list');
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) {
@@ -56,9 +71,6 @@
$events_loc.leads.tab[exhibit_id] = new_tab;
}
// Mock sign-in state for now
let is_signed_in = $state(true);
let tracking_id_li: Array<string> = $state([]); let search_debounce_timer: any = null;
let last_search_id = 0;
let last_executed_key = '';
@@ -296,6 +308,16 @@
set_active_tab('manage');
}
}
function handle_signout() {
const exhibit_id = page.params.exhibit_id;
if (!exhibit_id) return;
if (confirm('Sign out from this booth?')) {
delete $events_loc.leads.auth_exhibit_kv[exhibit_id];
$events_sess.leads.entered_passcode = null;
set_active_tab('start');
}
}
</script>
<section
@@ -311,46 +333,58 @@
</div>
<div class="flex items-center gap-1 sm:gap-2">
<!-- Add Lead / Lead List Toggle -->
<button
type="button"
class="btn btn-sm variant-filled-primary font-bold shadow-sm px-2 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>
{:else}
<Plus size="1.25em" class="sm:mr-2" />
<span class="hidden sm:inline">Add Lead</span>
{/if}
</button>
{#if is_signed_in}
<!-- Add Lead / Lead List Toggle -->
<button
type="button"
class="btn btn-sm variant-filled-primary font-bold shadow-sm px-2 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>
{:else}
<Plus size="1.25em" class="sm:mr-2" />
<span class="hidden sm:inline">Add Lead</span>
{/if}
</button>
<!-- Payment (Conditional) -->
{#if $ae_loc.show_leads_payment}
<!-- Payment (Conditional) -->
{#if $ae_loc.show_leads_payment}
<button
type="button"
class="btn btn-sm transition-colors px-2 sm:px-3"
class:variant-filled-success={active_tab === 'payment'}
class:variant-ghost-success={active_tab !== 'payment'}
onclick={() => set_active_tab('payment')}
title="Payment & Upgrades"
>
<CreditCard size="1.25em" />
</button>
{/if}
<!-- Manage / Config -->
<button
type="button"
class="btn btn-sm transition-colors px-2 sm:px-3"
class:variant-filled-success={active_tab === 'payment'}
class:variant-ghost-success={active_tab !== 'payment'}
onclick={() => set_active_tab('payment')}
title="Payment & Upgrades"
class:variant-filled-surface={active_tab === 'manage'}
class:variant-ghost-surface={active_tab !== 'manage'}
onclick={toggle_manage_tab}
title="Manage Exhibit"
>
<CreditCard size="1.25em" />
<Settings size="1.25em" />
</button>
<!-- Sign Out -->
<button
type="button"
class="btn btn-sm variant-ghost-error px-2 sm:px-3"
onclick={handle_signout}
title="Sign Out"
>
<LogOut size="1.25em" />
</button>
{/if}
<!-- Manage / Config -->
<button
type="button"
class="btn btn-sm transition-colors px-2 sm:px-3"
class:variant-filled-surface={active_tab === 'manage'}
class:variant-ghost-surface={active_tab !== 'manage'}
onclick={toggle_manage_tab}
title="Manage Exhibit"
>
<Settings size="1.25em" />
</button>
</div>
</header>

View File

@@ -1,11 +1,203 @@
<script lang="ts">
/**
* src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_comp__exhibit_signin.svelte
* Exhibitor Login Form Stub.
* 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 { events_loc, events_sess } from '$lib/stores/ae_events_stores';
import { Key, Mail, Lock, User, ArrowRight, LoaderCircle, AlertCircle, CheckCircle2 } from 'lucide-svelte';
import { untrack } from '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);
})
);
// 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('');
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 {
const licenses = JSON.parse($lq__exhibit_obj.license_li_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.';
}
}
}
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 = {};
$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)
}
</script>
<div class="exhibit-signin p-4 card">
<h3 class="h3">Exhibitor Sign In</h3>
<p>Placeholder for login logic.</p>
<div class="exhibit-signin card p-6 variant-filled-surface shadow-xl border border-surface-500/20 space-y-6">
<!-- Tab Toggle -->
<div class="flex p-1 bg-surface-500/10 rounded-xl">
<button
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-lg 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'}
>
<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:bg-surface-100-900={signin_mode === 'licensed'}
class:shadow-sm={signin_mode === 'licensed'}
class:opacity-50={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">
{#if signin_mode === 'passcode'}
<div class="space-y-2 animate-in fade-in slide-in-from-left-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] variant-soft rounded-xl overflow-hidden border border-surface-500/20">
<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"
/>
</div>
</label>
</div>
{:else}
<div class="space-y-4 animate-in fade-in slide-in-from-right-2">
<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] variant-soft rounded-xl overflow-hidden border border-surface-500/20">
<div class="input-group-shim"><Mail size="1.2em" /></div>
<input
type="email"
bind:value={email}
placeholder="your@email.com"
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] variant-soft rounded-xl overflow-hidden border border-surface-500/20">
<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"
/>
</div>
</label>
</div>
{/if}
{#if status === 'error'}
<div class="p-3 rounded-lg variant-soft-error flex items-start gap-3 animate-shake">
<AlertCircle size="1.2em" class="shrink-0 mt-0.5" />
<p class="text-xs font-bold leading-tight">{error_msg}</p>
</div>
{/if}
<button
type="submit"
class="btn btn-lg variant-filled-primary w-full font-bold shadow-lg shadow-primary-500/20 group"
disabled={status === 'submitting'}
>
{#if status === 'submitting'}
<LoaderCircle size="1.5em" class="animate-spin mr-2" />
Signing In...
{:else if status === 'success'}
<CheckCircle2 size="1.5em" class="mr-2" />
Welcome!
{:else}
Get Started <ArrowRight size="1.2em" class="ml-2 group-hover:translate-x-1 transition-transform" />
{/if}
</button>
</form>
<p class="text-[10px] text-center opacity-40 italic">
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); }
}
.animate-shake {
animation: shake 0.2s ease-in-out 0s 2;
}
</style>

View File

@@ -1,7 +1,64 @@
<script lang="ts">
// Page for viewing/editing a single lead
/**
* 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 { LayoutGrid, CheckCircle2, UserCheck, ShieldCheck } 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);
})
);
</script>
<h1 class="h1">Lead Details</h1>
<div class="ae-tab-start w-full space-y-8 animate-in fade-in slide-in-from-bottom-2 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">
<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>
</section>
<p>This page will show the details for a single lead. A Lead is actually in the event_exhibit_tracking table in the MariaDB or exhibit_tracking table in the Indexed DB ae_events_db.</p>
<!-- 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 variant-soft-surface">
<CheckCircle2 size="1.5em" class="text-success-500 mb-2" />
<span class="text-xs font-bold uppercase tracking-wider">Fast Capture</span>
</div>
<div class="flex flex-col items-center text-center p-4 rounded-xl variant-soft-surface">
<UserCheck size="1.5em" class="text-secondary-500 mb-2" />
<span class="text-xs font-bold uppercase tracking-wider">Staff IDs</span>
</div>
<div class="flex flex-col items-center text-center p-4 rounded-xl variant-soft-surface">
<ShieldCheck size="1.5em" class="text-primary-500 mb-2" />
<span class="text-xs font-bold uppercase tracking-wider">Secure Sync</span>
</div>
</div>
<!-- Sign In Area -->
<div class="w-full max-w-md mx-auto">
<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>
</div>