feat(badges): print/review pages, 4-button list, Lucide icons, permissions doc

Badge search results list (ae_comp__badge_obj_li):
- 4 action buttons per row: Print, Review (nav link), Copy Link (clipboard), Email Link
- Visibility rules: unprinted-only for non-edit mode; all non-hidden for trusted+edit
- Plain name display (User/EyeOff icon) — name is no longer a print link
- Obscured email for non-trusted users
- Debug row (ID, CR, UP, PC, FP, LP) in edit mode
- All icons converted to Lucide (Font Awesome removed)

Badge print page (/print):
- 3 header action buttons: Print Now, Review (nav), Email Link
- Removed old [badge_id]/+page.svelte placeholder (moved to trash)
- Added is_trusted, is_edit_mode, print state derived vars
- "Already printed Nx — last [timestamp]" warning inline with name
- Removed unused imports (browser, onMount, events_slct)

Badge review page (/review):
- 3 header action buttons: Print (nav), Copy Link (clipboard), Email Link
- Added events_loc for email placeholder + title event name
- Added is_edit_mode, print_count, is_printed, copy_status
- FA icons replaced with Lucide (ShieldCheck, UserCheck, User)
- Title now includes event name (was missing)

Infrastructure:
- print/+page.ts and review/+page.ts added (non-blocking badge loaders)
- ae_comp__badge_review_form.svelte stub created (fields pending)
- Fixed: components no longer write to $ae_loc.edit_mode (critical bug)

Docs:
- NEW: AE__Permissions_and_Security.md — full permissions hierarchy reference
- NEW: PROJECT__AE_Events_Badges_Review_Print.md — agent task brief for review form + print font controls
- UPDATED: MODULE__AE_Events_Badges.md rev 5 — field permissions spec, header buttons, still-needed list by priority

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-02-27 15:12:22 -05:00
parent ee500a9ad5
commit c4e85b1fe3
15 changed files with 2046 additions and 412 deletions

View File

@@ -1,199 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
interface Props {
/** @type {import('./$types').PageData} */
data: any;
log_lvl?: number;
}
let { data, log_lvl = 0 }: Props = $props();
// *** Import Svelte specific
// import { goto } from '$app/navigation';
// *** Import other supporting libraries
import { browser } from '$app/environment';
import { liveQuery } from 'dexie';
// *** Import Aether specific variables and functions
// import type { key_val } from '$lib/ae_stores';
// import { ae_util } from '$lib/ae_utils/ae_utils';
// import { core_func } from '$lib/ae_core_functions';
import { ae_loc } from '$lib/stores/ae_stores';
import { db_events } from '$lib/ae_events/db_events';
// import { ae_snip, ae_loc, ae_sess, ae_api, ae_trig, slct, slct_trigger } from '$lib/ae_stores';
import {
events_loc,
events_sess,
events_slct,
events_trigger
} from '$lib/stores/ae_events_stores';
// import { events_func } from '$lib/ae_events_functions';
import Comp_badge_obj_view from './ae_comp__badge_obj_view.svelte';
import { page } from '$app/state';
import { LoaderCircle } from 'lucide-svelte';
// *** Variables
// Use page.params for robust reactivity in Svelte 5
let event_badge_id = $derived(page.params.badge_id);
// Track if we are waiting for initial IDB result
let is_loading_idb = $state(true);
// let url_test_val = $derived(data.url.searchParams.get('test_val'));
// $effect(() => {
// console.log(`URL test_val = ${url_test_val}`);
// });
let lq__event_badge_obj = $derived(
liveQuery(async () => {
if (!event_badge_id) return null;
if (log_lvl) {
console.log(
`*** LiveQuery: lq__event_badge_obj *** event_badge_id=${event_badge_id}`
);
}
let results = await db_events.badge.get(event_badge_id);
if (log_lvl) {
console.log(
`*** LiveQuery: lq__event_badge_obj *** results=`,
results
);
}
return results;
})
);
// SIDE EFFECT: Update loading state when the observable value changes
$effect(() => {
if ($lq__event_badge_obj !== undefined) {
untrack(() => is_loading_idb = false);
}
});
let lq__event_badge_template_obj = $derived(
liveQuery(async () => {
let results = await db_events.badge_template.get(
$lq__event_badge_obj?.event_badge_template_id ?? ''
); // null or undefined does not reset things like '' does
if (log_lvl) {
console.log(
`*** LiveQuery: lq__event_badge_template_obj *** event_badge_template_id=${
$lq__event_badge_obj?.event_badge_template_id ?? ''
}`,
results
);
}
// Check if results are different than the current session version stored under $events_slct
// if ($events_slct.event_badge_obj && results) {
// if (JSON.stringify($events_slct.event_badge_obj) !== JSON.stringify(results)) {
// $events_slct.event_badge_obj = { ...results };
// }
// }
return results;
})
);
let is_review_mode: boolean = $state(false);
// *** Functions and Logic
import { onMount } from 'svelte';
let lq__event_obj: any = $state(undefined);
onMount(() => {
const observable = liveQuery(() =>
db_events.event.get($events_slct?.event_id ?? '')
);
const subscription = observable.subscribe((value) => {
lq__event_obj = value;
});
if (browser && window.location.hash === '#review') {
is_review_mode = true;
$ae_loc.edit_mode = true;
} else {
is_review_mode = false;
$ae_loc.edit_mode = false;
}
return () => {
subscription.unsubscribe();
};
});
</script>
<svelte:head>
<title>
&AElig;: Badge -
{$lq__event_badge_obj?.given_name ?? '-- not set --'}
{$lq__event_badge_obj?.family_name
? $lq__event_badge_obj?.family_name.charAt(0) + '.'
: ''}
- Badges v3 -
{$events_loc?.title}
</title>
</svelte:head>
<!-- badge ID +page: Where is here??? -->
<!-- event {data.params.event_id} / badge {data.params.badge_id} -->
{#if $lq__event_badge_obj}
<header
class="
w-full
flex flex-row gap-1 items-center justify-between
border-b border-gray-300
mb-2
pb-2
"
>
<h2 class="text-2xl font-bold">
Badge:
{#if $lq__event_badge_obj.full_name}
{$lq__event_badge_obj.full_name}
{:else if $lq__event_badge_obj.given_name}
{$lq__event_badge_obj.given_name}
{:else}
-- no name --
{/if}
</h2>
<a
href={`/events/${$lq__event_badge_obj.event_id}/badges`}
class="text-sm italic text-blue-600 hover:underline"
>
<span class="fas fa-search"></span>
Back to Search
</a>
</header>
{#if $lq__event_badge_obj && $lq__event_badge_obj.event_id && event_badge_id}
<Comp_badge_obj_view
event_id={$lq__event_badge_obj.event_id as string}
event_badge_id={event_badge_id as string}
{lq__event_badge_obj}
{is_review_mode}
{lq__event_badge_template_obj}
/>
{/if}
{:else if is_loading_idb || !event_badge_id}
<div class="flex flex-col items-center justify-center p-20 gap-4 opacity-50">
<LoaderCircle size="3em" class="animate-spin" />
<p class="text-xl font-bold text-center">Loading Badge Details...</p>
</div>
{:else}
<div class="card p-8 variant-soft-error border border-error-500/30 text-center space-y-4">
<h2 class="h2 font-black text-error-500">Badge Not Found</h2>
<p class="opacity-70">No record found locally for ID: <span class="font-mono">{event_badge_id}</span></p>
<button class="btn variant-filled-primary" onclick={() => window.location.reload()}>
<span class="fas fa-sync mr-2"></span> Force Refresh
</button>
</div>
{/if}

View File

@@ -125,12 +125,12 @@
editable_badge_type_code =
$lq__event_badge_obj.badge_type_code ?? null;
// Only set the local edit state — never touch $ae_loc.edit_mode here.
// $ae_loc.edit_mode is a user preference; this component must not override it.
if (is_review_mode) {
edit_mode_active = true;
$ae_loc.edit_mode = true;
} else {
edit_mode_active = false; // Ensure it starts off if not in review mode
$ae_loc.edit_mode = false;
edit_mode_active = false;
}
}
});
@@ -420,7 +420,6 @@
update_complete = true;
if (!is_review_mode) {
edit_mode_active = false;
$ae_loc.edit_mode = false;
}
return;
}
@@ -437,7 +436,6 @@
update_complete = true;
if (!is_review_mode) {
edit_mode_active = false;
$ae_loc.edit_mode = false;
}
// Optionally, refresh the $lq__event_badge_obj if needed, though Dexie might handle it
} catch (error) {
@@ -469,7 +467,6 @@
}
if (!is_review_mode) {
edit_mode_active = false;
$ae_loc.edit_mode = false;
}
update_status = 'idle';
update_complete = true;
@@ -509,6 +506,9 @@
print_status = 'done';
console.log(`Badge printed. Count: ${data_to_update.print_count}`);
// Trigger browser print dialog (records the print count first so it's always logged)
if (browser) window.print();
// Brief success flash, then return to badge search
setTimeout(() => {
goto(`/events/${event_id}/badges`);
@@ -658,7 +658,6 @@ onkeypress={() => {
"
onclick={() => {
edit_mode_active = true;
$ae_loc.edit_mode = true;
}}
title="Edit Badge Information"
data-testid="badge-edit-btn"

View File

@@ -0,0 +1,382 @@
<script lang="ts">
/**
* Badge Review Form
*
* A form-based view of badge fields for attendees and staff to review/edit.
* Does NOT render the badge layout — that is handled by ae_comp__badge_obj_view.svelte.
*
* Access:
* - Attendees (passcode-validated): can edit fields listed in can_edit_fields
* - Trusted staff: can_edit_fields + additional staff-only fields
* - Administrators: all override fields
*
* Field display is driven by the `can_edit_fields` prop, which comes from
* event.mod_badges_json.edit_permissions (see ae_comp__event_settings_badges_form.svelte).
*/
interface Props {
event_id: string;
event_badge_id: string;
lq__event_badge_obj?: any;
can_edit_fields?: string[]; // Whitelist of field names attendee/staff may edit
is_staff?: boolean; // True = staff view (shows read-only source fields too)
log_lvl?: number;
}
let {
event_id,
event_badge_id,
lq__event_badge_obj,
can_edit_fields = ['full_name_override', 'professional_title_override', 'affiliations_override', 'location_override'],
is_staff = false,
log_lvl = 0
}: Props = $props();
import type { key_val } from '$lib/stores/ae_stores';
import { ae_api } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events_functions';
// *** Editable field state (only fields in can_edit_fields are active)
let editable_full_name_override: string = $state('');
let editable_professional_title_override: string = $state('');
let editable_affiliations_override: string = $state('');
let editable_location_override: string = $state('');
let editable_email: string = $state('');
let editable_badge_type_code: string = $state('');
let save_status: 'idle' | 'loading' | 'done' | 'error' = $state('idle');
// Initialize from badge object
$effect(() => {
if ($lq__event_badge_obj) {
editable_full_name_override =
$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '';
editable_professional_title_override =
$lq__event_badge_obj.professional_title_override ?? $lq__event_badge_obj.professional_title ?? '';
editable_affiliations_override =
$lq__event_badge_obj.affiliations_override ?? $lq__event_badge_obj.affiliations ?? '';
editable_location_override =
$lq__event_badge_obj.location_override ?? $lq__event_badge_obj.location ?? '';
editable_email =
$lq__event_badge_obj.email ?? '';
editable_badge_type_code =
$lq__event_badge_obj.badge_type_code ?? '';
}
});
// Derived: is this field editable for the current user?
function can_edit(field: string): boolean {
return can_edit_fields.includes('*') || can_edit_fields.includes(field);
}
function has_override(base_field: string, override_field: string): boolean {
return !!(
$lq__event_badge_obj?.[override_field] &&
$lq__event_badge_obj[override_field] !== $lq__event_badge_obj[base_field]
);
}
async function handle_save() {
if (!$lq__event_badge_obj?.event_badge_id) return;
save_status = 'loading';
const data_to_update: key_val = {};
// Only include fields in can_edit_fields that have changed
if (can_edit('full_name_override')) {
const original = $lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '';
if (editable_full_name_override !== original) {
data_to_update.full_name_override = editable_full_name_override || null;
}
}
if (can_edit('professional_title_override')) {
const original = $lq__event_badge_obj.professional_title_override ?? $lq__event_badge_obj.professional_title ?? '';
if (editable_professional_title_override !== original) {
data_to_update.professional_title_override = editable_professional_title_override || null;
}
}
if (can_edit('affiliations_override')) {
const original = $lq__event_badge_obj.affiliations_override ?? $lq__event_badge_obj.affiliations ?? '';
if (editable_affiliations_override !== original) {
data_to_update.affiliations_override = editable_affiliations_override || null;
}
}
if (can_edit('location_override')) {
const original = $lq__event_badge_obj.location_override ?? $lq__event_badge_obj.location ?? '';
if (editable_location_override !== original) {
data_to_update.location_override = editable_location_override || null;
}
}
if (can_edit('email')) {
if (editable_email !== ($lq__event_badge_obj.email ?? '')) {
data_to_update.email = editable_email || null;
}
}
if (can_edit('badge_type_code')) {
if (editable_badge_type_code !== ($lq__event_badge_obj.badge_type_code ?? '')) {
data_to_update.badge_type_code = editable_badge_type_code || null;
}
}
if (Object.keys(data_to_update).length === 0) {
save_status = 'done';
return;
}
try {
await events_func.update_ae_obj__event_badge({
api_cfg: $ae_api,
event_id,
event_badge_id: $lq__event_badge_obj.event_badge_id,
data_kv: data_to_update,
log_lvl
});
save_status = 'done';
} catch (error) {
console.error('Error saving badge review changes:', error);
save_status = 'error';
}
}
function handle_cancel() {
if ($lq__event_badge_obj) {
editable_full_name_override =
$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '';
editable_professional_title_override =
$lq__event_badge_obj.professional_title_override ?? $lq__event_badge_obj.professional_title ?? '';
editable_affiliations_override =
$lq__event_badge_obj.affiliations_override ?? $lq__event_badge_obj.affiliations ?? '';
editable_location_override =
$lq__event_badge_obj.location_override ?? $lq__event_badge_obj.location ?? '';
editable_email = $lq__event_badge_obj.email ?? '';
editable_badge_type_code = $lq__event_badge_obj.badge_type_code ?? '';
}
save_status = 'idle';
}
</script>
{#if $lq__event_badge_obj}
<div class="space-y-6 max-w-2xl">
<!-- Source data header (staff only) -->
{#if is_staff}
<div class="card p-3 variant-soft-surface text-sm space-y-1">
<p class="font-semibold text-xs uppercase tracking-wide text-gray-500">Source Data (from registration system — read only)</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-0.5 text-xs">
<span class="text-gray-500">Given Name</span>
<span>{$lq__event_badge_obj.given_name ?? '—'}</span>
<span class="text-gray-500">Family Name</span>
<span>{$lq__event_badge_obj.family_name ?? '—'}</span>
<span class="text-gray-500">Full Name</span>
<span>{$lq__event_badge_obj.full_name ?? '—'}</span>
<span class="text-gray-500">Professional Title</span>
<span>{$lq__event_badge_obj.professional_title ?? '—'}</span>
<span class="text-gray-500">Affiliations</span>
<span>{$lq__event_badge_obj.affiliations ?? '—'}</span>
<span class="text-gray-500">Location</span>
<span>{$lq__event_badge_obj.location ?? '—'}</span>
<span class="text-gray-500">Badge Type</span>
<span>{$lq__event_badge_obj.badge_type_code ?? '—'}</span>
</div>
</div>
{/if}
<!-- Editable fields -->
<div class="space-y-4">
<p class="text-sm text-gray-500 italic">
Fields below are what will appear on your printed badge.
{#if can_edit_fields.length > 0}
You may edit the highlighted fields.
{/if}
</p>
<!-- Full Name -->
{#if can_edit('full_name_override') || is_staff}
<div class="space-y-1">
<label for="badge-review-full-name" class="block text-sm font-medium">
Full Name (on badge)
{#if has_override('full_name', 'full_name_override')}
<span class="text-xs text-amber-600 ml-1">(overridden)</span>
{/if}
</label>
{#if can_edit('full_name_override')}
<input
id="badge-review-full-name"
type="text"
class="input w-full"
bind:value={editable_full_name_override}
placeholder={$lq__event_badge_obj.full_name ?? 'Full name'}
data-testid="badge-review-full-name-input"
/>
{:else}
<p class="input w-full bg-gray-50 opacity-60 cursor-not-allowed">
{$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '—'}
</p>
{/if}
</div>
{/if}
<!-- Professional Title -->
{#if can_edit('professional_title_override') || is_staff}
<div class="space-y-1">
<label for="badge-review-pro-title" class="block text-sm font-medium">
Professional Title (on badge)
{#if has_override('professional_title', 'professional_title_override')}
<span class="text-xs text-amber-600 ml-1">(overridden)</span>
{/if}
</label>
{#if can_edit('professional_title_override')}
<input
id="badge-review-pro-title"
type="text"
class="input w-full"
bind:value={editable_professional_title_override}
placeholder={$lq__event_badge_obj.professional_title ?? 'Professional title'}
data-testid="badge-review-pro-title-input"
/>
{:else}
<p class="input w-full bg-gray-50 opacity-60 cursor-not-allowed">
{$lq__event_badge_obj.professional_title_override ?? $lq__event_badge_obj.professional_title ?? '—'}
</p>
{/if}
</div>
{/if}
<!-- Affiliations -->
{#if can_edit('affiliations_override') || is_staff}
<div class="space-y-1">
<label for="badge-review-affiliations" class="block text-sm font-medium">
Affiliations / Organization (on badge)
{#if has_override('affiliations', 'affiliations_override')}
<span class="text-xs text-amber-600 ml-1">(overridden)</span>
{/if}
</label>
{#if can_edit('affiliations_override')}
<textarea
id="badge-review-affiliations"
class="textarea w-full"
rows="2"
bind:value={editable_affiliations_override}
placeholder={$lq__event_badge_obj.affiliations ?? 'Affiliations / organization'}
data-testid="badge-review-affiliations-input"
></textarea>
{:else}
<p class="input w-full bg-gray-50 opacity-60 cursor-not-allowed">
{$lq__event_badge_obj.affiliations_override ?? $lq__event_badge_obj.affiliations ?? '—'}
</p>
{/if}
</div>
{/if}
<!-- Location -->
{#if can_edit('location_override') || is_staff}
<div class="space-y-1">
<label for="badge-review-location" class="block text-sm font-medium">
Location (on badge)
{#if has_override('location', 'location_override')}
<span class="text-xs text-amber-600 ml-1">(overridden)</span>
{/if}
</label>
{#if can_edit('location_override')}
<input
id="badge-review-location"
type="text"
class="input w-full"
bind:value={editable_location_override}
placeholder={$lq__event_badge_obj.location ?? 'City, State/Province, Country'}
data-testid="badge-review-location-input"
/>
{:else}
<p class="input w-full bg-gray-50 opacity-60 cursor-not-allowed">
{$lq__event_badge_obj.location_override ?? $lq__event_badge_obj.location ?? '—'}
</p>
{/if}
</div>
{/if}
<!-- Email (staff only by default) -->
{#if can_edit('email')}
<div class="space-y-1">
<label for="badge-review-email" class="block text-sm font-medium">
Email
</label>
<input
id="badge-review-email"
type="email"
class="input w-full"
bind:value={editable_email}
placeholder="Email address"
data-testid="badge-review-email-input"
/>
</div>
{/if}
<!-- Badge Type Code (staff only by default) -->
{#if can_edit('badge_type_code')}
<div class="space-y-1">
<label for="badge-review-badge-type" class="block text-sm font-medium">
Badge Type Code
</label>
<input
id="badge-review-badge-type"
type="text"
class="input w-full"
bind:value={editable_badge_type_code}
placeholder="e.g. current_member, guest, staff"
data-testid="badge-review-badge-type-input"
/>
</div>
{/if}
</div>
<!-- Save / Cancel -->
{#if can_edit_fields.length > 0}
<div class="flex flex-row gap-2 items-center">
<button
type="button"
class="btn preset-filled-primary"
class:preset-tonal-primary={save_status === 'loading'}
class:preset-filled-success-500={save_status === 'done'}
class:preset-filled-error-500={save_status === 'error'}
disabled={save_status === 'loading'}
onclick={handle_save}
data-testid="badge-review-save-btn"
>
{#if save_status === 'loading'}
<span class="fas fa-spinner fa-spin mr-2"></span> Saving…
{:else if save_status === 'done'}
<span class="fas fa-check mr-2"></span> Saved
{:else if save_status === 'error'}
<span class="fas fa-exclamation-triangle mr-2"></span> Error — try again
{:else}
<span class="fas fa-save mr-2"></span> Save Changes
{/if}
</button>
{#if save_status !== 'done'}
<button
type="button"
class="btn preset-tonal-surface"
onclick={handle_cancel}
data-testid="badge-review-cancel-btn"
>
Cancel
</button>
{/if}
</div>
{/if}
<!-- Print info (read-only) -->
{#if is_staff && $lq__event_badge_obj.print_count !== undefined}
<div class="text-xs text-gray-500 space-y-0.5 border-t border-gray-200 pt-3 mt-2">
<p>Print count: <strong>{$lq__event_badge_obj.print_count ?? 0}</strong></p>
{#if $lq__event_badge_obj.print_first_datetime}
<p>First printed: {new Date($lq__event_badge_obj.print_first_datetime).toLocaleString()}</p>
{/if}
{#if $lq__event_badge_obj.print_last_datetime}
<p>Last printed: {new Date($lq__event_badge_obj.print_last_datetime).toLocaleString()}</p>
{/if}
</div>
{/if}
</div>
{/if}

View File

@@ -1,7 +1,199 @@
<script lang="ts">
// Page for printing badges
interface Props {
/** @type {import('./$types').PageData} */
data: any;
log_lvl?: number;
}
let { data, log_lvl = 0 }: Props = $props();
import { untrack } from 'svelte';
import { liveQuery } from 'dexie';
import { ae_loc } from '$lib/stores/ae_stores';
import { db_events } from '$lib/ae_events/db_events';
import { events_loc } from '$lib/stores/ae_events_stores';
import { page } from '$app/state';
import { ArrowLeft, Eye, LoaderCircle, Mail, Printer } from 'lucide-svelte';
import Comp_badge_obj_view from '../ae_comp__badge_obj_view.svelte';
let event_badge_id = $derived(page.params.badge_id);
let event_id = $derived(page.params.event_id);
let is_loading_idb = $state(true);
let lq__event_badge_obj = $derived(
liveQuery(async () => {
if (!event_badge_id) return null;
return await db_events.badge.get(event_badge_id);
})
);
$effect(() => {
if ($lq__event_badge_obj !== undefined) {
untrack(() => (is_loading_idb = false));
}
});
let lq__event_badge_template_obj = $derived(
liveQuery(async () => {
return await db_events.badge_template.get(
$lq__event_badge_obj?.event_badge_template_id ?? ''
);
})
);
// Access level shortcuts
let is_trusted = $derived($ae_loc.trusted_access === true);
let is_edit_mode = $derived($ae_loc.edit_mode === true);
// Print state derived from badge
let print_count = $derived($lq__event_badge_obj?.print_count ?? 0);
let is_printed = $derived(print_count >= 1);
function build_review_url(): string {
// TODO: append ?passcode=... when person_passcode is added to the event_badge schema
return `/events/${$lq__event_badge_obj?.event_id}/badges/${$lq__event_badge_obj?.event_badge_id}/review`;
}
function obscure_email(email: string | null | undefined): string {
if (!email) return '';
const at = email.indexOf('@');
if (at < 0) return email;
return `${email.slice(0, Math.min(3, at))}***${email.slice(at)}`;
}
// TODO: replace alert with actual email API call when available
function send_review_email() {
const badge = $lq__event_badge_obj;
const name =
badge?.full_name_override
?? badge?.full_name
?? `${badge?.given_name ?? ''} ${badge?.family_name ?? ''}`.trim();
const email = is_trusted
? (badge?.email ?? '(no email on file)')
: obscure_email(badge?.email);
const event_name = $events_loc?.title ?? 'this event';
alert(`PLACEHOLDER: An email will be sent to ${name} at ${email}. Use that link to review your ${event_name} badge.`);
}
// Print mode: display only — ae_comp__badge_obj_view handles print count + window.print()
// The "Print Now" header button below only triggers window.print() for convenience;
// print count is incremented by the print button inside ae_comp__badge_obj_view.
</script>
<h1 class="h1">Print Badges</h1>
<svelte:head>
<title>
&AElig;: Print Badge —
{$lq__event_badge_obj?.full_name_override ?? $lq__event_badge_obj?.full_name ?? '—'}
{$events_loc?.title ? ` — ${$events_loc.title}` : ''}
</title>
</svelte:head>
<p>This page will be used for printing badges.</p>
{#if $lq__event_badge_obj && $lq__event_badge_obj.event_id && event_badge_id}
<!-- Print chrome: hidden when browser prints (print:hidden) -->
<header class="print:hidden w-full flex flex-row flex-wrap gap-2 items-center justify-between border-b border-gray-300 mb-3 pb-2">
<!-- Left: Back to Search + name + already-printed warning -->
<div class="flex flex-row gap-2 items-center min-w-0">
<a
href={`/events/${$lq__event_badge_obj.event_id}/badges`}
class="btn btn-sm preset-tonal-surface flex items-center gap-1 shrink-0"
title="Back to badge search"
>
<ArrowLeft size="1em" />
<span class="hidden sm:inline">Search</span>
</a>
<div class="flex flex-col min-w-0">
<h2 class="text-base font-bold truncate">
{$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '— no name —'}
</h2>
{#if is_printed}
<p class="text-xs text-amber-600 font-medium">
Printed {print_count}×
{#if $lq__event_badge_obj.print_last_datetime}
— last {new Date($lq__event_badge_obj.print_last_datetime).toLocaleString()}
{/if}
</p>
{/if}
</div>
</div>
<!-- Right: Action buttons -->
<div class="flex flex-row gap-1 items-center shrink-0">
<!-- 1. Print Now: Trusted+, not printed OR Edit Mode (reprint) -->
{#if is_trusted && (!is_printed || is_edit_mode)}
<button
type="button"
class="btn btn-sm preset-tonal-primary flex items-center gap-1"
onclick={() => window.print()}
title={is_printed ? `Reprint badge (printed ${print_count}×)` : 'Print badge now'}
>
<Printer size="1em" />
{#if is_printed}
<span class="font-bold text-xs">{print_count}×</span>
{/if}
<span class="hidden sm:inline">Print Now</span>
</button>
{/if}
<!-- 2. Direct Review link: Trusted + Edit Mode -->
{#if is_trusted && is_edit_mode}
<a
href={build_review_url()}
class="btn btn-sm preset-tonal-secondary flex items-center gap-1"
title="Open badge review page"
>
<Eye size="1em" />
<span class="hidden sm:inline">Review</span>
</a>
{/if}
<!-- 3. Email Review Link: all if not printed; Trusted+Edit if printed
TODO: replace alert with actual email API call -->
{#if !is_printed || (is_trusted && is_edit_mode)}
<button
type="button"
class="btn btn-sm preset-tonal-surface flex items-center gap-1"
onclick={() => send_review_email()}
title="Email a review link to this attendee"
>
<Mail size="1em" />
<span class="hidden sm:inline">Email Link</span>
</button>
{/if}
</div>
</header>
<!-- Badge render — ae_comp__badge_obj_view handles print count + window.print() -->
<Comp_badge_obj_view
event_id={$lq__event_badge_obj.event_id as string}
event_badge_id={event_badge_id as string}
{lq__event_badge_obj}
{lq__event_badge_template_obj}
is_review_mode={false}
/>
{:else if is_loading_idb || !event_badge_id}
<div class="flex flex-col items-center justify-center p-20 gap-4 opacity-50">
<LoaderCircle size="3em" class="animate-spin" />
<p class="text-xl font-bold text-center">Loading Badge...</p>
</div>
{:else}
<div class="card p-8 text-center space-y-4">
<h2 class="text-2xl font-bold text-error-500">Badge Not Found</h2>
<p class="opacity-70">No record found for ID: <span class="font-mono">{event_badge_id}</span></p>
<div class="flex gap-2 justify-center">
<button class="btn preset-filled-primary" onclick={() => window.location.reload()}>
Retry
</button>
<a href={`/events/${event_id}/badges`} class="btn preset-tonal-surface">
Back to Search
</a>
</div>
</div>
{/if}

View File

@@ -0,0 +1,31 @@
/** @type {import('./$types').PageLoad} */
console.log(`Events - Badges [badge_id]/print +page.ts start`);
import { browser } from '$app/environment';
import { events_func } from '$lib/ae_events_functions';
// Loads badge + template for the print page (non-blocking background refresh)
export async function load({ params, parent }) {
const log_lvl: number = 0;
const parent_data = await parent();
const account_id = parent_data.account_id;
const ae_acct = parent_data[account_id];
if (browser) {
const event_id = params.event_id;
const event_badge_id = params.badge_id;
if (event_badge_id) {
events_func.load_ae_obj_id__event_badge({
api_cfg: ae_acct.api,
event_badge_id: event_badge_id,
event_id: event_id,
inc_template: true,
log_lvl: log_lvl
});
}
}
return { params };
}

View File

@@ -1,7 +1,358 @@
<script lang="ts">
// Page for reviewing badges
/**
* Badge Review Page
*
* Access modes (checked in order):
* 1. Administrator ($ae_loc.administrator_access) — full edit access, no passcode needed
* 2. Trusted staff ($ae_loc.trusted_access) — trusted-level fields, no passcode needed
* 3. Attendee (URL ?passcode=... matches badge.person_passcode) — attendee-level fields
* 4. Passcode entry form — if none of the above, prompt for passcode
*
* Editable fields per level come from event.mod_badges_json.edit_permissions.
* Defaults apply if not configured in event settings.
*/
interface Props {
/** @type {import('./$types').PageData} */
data: any;
log_lvl?: number;
}
let { data, log_lvl = 0 }: Props = $props();
import { untrack } from 'svelte';
import { liveQuery } from 'dexie';
import { ae_loc } from '$lib/stores/ae_stores';
import { db_events } from '$lib/ae_events/db_events';
import { events_loc } from '$lib/stores/ae_events_stores';
import { page } from '$app/state';
import {
ArrowLeft,
Check,
Link,
LoaderCircle,
Mail,
Printer,
ShieldCheck,
User,
UserCheck
} from 'lucide-svelte';
import Comp_badge_review_form from '../ae_comp__badge_review_form.svelte';
let event_badge_id = $derived(page.params.badge_id);
let event_id = $derived(page.params.event_id);
let is_loading_idb = $state(true);
// *** LiveQuery: badge object
let lq__event_badge_obj = $derived(
liveQuery(async () => {
if (!event_badge_id) return null;
return await db_events.badge.get(event_badge_id);
})
);
$effect(() => {
if ($lq__event_badge_obj !== undefined) {
untrack(() => (is_loading_idb = false));
}
});
// TODO: Load event.mod_badges_json.edit_permissions for per-event field config.
// Hardcoded defaults for now — revisit after basic flow is working.
const default_authenticated_fields = [
'full_name_override',
'professional_title_override',
'affiliations_override',
'location_override'
];
const default_trusted_fields = [
'full_name_override',
'professional_title_override',
'affiliations_override',
'location_override',
'email',
'badge_type_code'
];
// *** Passcode logic
let url_passcode = $derived(page.url?.searchParams?.get('passcode') ?? '');
let entered_passcode = $state('');
let passcode_checked = $state(false);
let passcode_valid = $state(false);
let passcode_error = $state('');
// Auto-validate URL passcode once badge is loaded
$effect(() => {
if (url_passcode && $lq__event_badge_obj && !passcode_checked) {
untrack(() => {
check_passcode(url_passcode);
});
}
});
function check_passcode(code: string) {
passcode_checked = true;
const badge_passcode = $lq__event_badge_obj?.person_passcode;
if (!badge_passcode) {
// No passcode set on badge — deny access to prevent unintentional open access
passcode_valid = false;
passcode_error = 'This badge does not have a review link enabled. Please contact event staff.';
} else if (code && code === badge_passcode) {
passcode_valid = true;
passcode_error = '';
} else {
passcode_valid = false;
passcode_error = 'Incorrect passcode. Please check your email link or contact event staff.';
}
}
// *** Access level shortcuts
let is_administrator = $derived($ae_loc?.administrator_access === true);
let is_trusted = $derived($ae_loc?.trusted_access === true);
let is_edit_mode = $derived($ae_loc?.edit_mode === true);
let has_staff_access = $derived(is_administrator || is_trusted);
let has_attendee_access = $derived(passcode_valid);
let has_any_access = $derived(has_staff_access || has_attendee_access);
// *** Print state derived from badge
let print_count = $derived($lq__event_badge_obj?.print_count ?? 0);
let is_printed = $derived(print_count >= 1);
// *** Copy review link state
let copy_status: 'idle' | 'copied' = $state('idle');
function build_review_url(): string {
// TODO: append ?passcode=... when person_passcode is added to the event_badge schema
return `/events/${$lq__event_badge_obj?.event_id}/badges/${$lq__event_badge_obj?.event_badge_id}/review`;
}
async function copy_review_link() {
const full_url = window.location.origin + build_review_url();
try {
await navigator.clipboard.writeText(full_url);
copy_status = 'copied';
setTimeout(() => {
copy_status = 'idle';
}, 2000);
} catch {
console.error('Clipboard write failed');
}
}
function obscure_email(email: string | null | undefined): string {
if (!email) return '';
const at = email.indexOf('@');
if (at < 0) return email;
return `${email.slice(0, Math.min(3, at))}***${email.slice(at)}`;
}
// TODO: replace alert with actual email API call when available
function send_review_email() {
const badge = $lq__event_badge_obj;
const name =
badge?.full_name_override
?? badge?.full_name
?? `${badge?.given_name ?? ''} ${badge?.family_name ?? ''}`.trim();
const email = is_trusted
? (badge?.email ?? '(no email on file)')
: obscure_email(badge?.email);
const event_name = $events_loc?.title ?? 'this event';
alert(`PLACEHOLDER: An email will be sent to ${name} at ${email}. Use that link to review your ${event_name} badge.`);
}
// *** Resolve editable field list based on access level
// Uses $derived.by() to return the array directly (not a function).
// TODO: Read from event.mod_badges_json.edit_permissions for per-event config.
let can_edit_fields: string[] = $derived.by(() => {
if (is_administrator) return ['*'];
if (is_trusted) return default_trusted_fields;
if (has_attendee_access) return default_authenticated_fields;
return [];
});
</script>
<h1 class="h1">Review Badges</h1>
<svelte:head>
<title>
&AElig;: Review Badge —
{$lq__event_badge_obj?.full_name_override ?? $lq__event_badge_obj?.full_name ?? '—'}
{$events_loc?.title ? ` — ${$events_loc.title}` : ''}
</title>
</svelte:head>
<p>This page will be used for reviewing badges.</p>
{#if $lq__event_badge_obj}
<!-- Page header -->
<header class="w-full flex flex-row flex-wrap gap-2 items-center justify-between border-b border-gray-300 mb-4 pb-2">
<!-- Left: Back to Search (staff only) + title + name -->
<div class="flex flex-row gap-2 items-center min-w-0">
{#if has_staff_access}
<a
href={`/events/${$lq__event_badge_obj.event_id}/badges`}
class="btn btn-sm preset-tonal-surface flex items-center gap-1 shrink-0"
title="Back to badge search"
>
<ArrowLeft size="1em" />
<span class="hidden sm:inline">Search</span>
</a>
{/if}
<div class="flex flex-col min-w-0">
<h2 class="text-base font-bold">Review Badge</h2>
<p class="text-sm text-gray-500 truncate">
{$lq__event_badge_obj.full_name_override ?? $lq__event_badge_obj.full_name ?? '— no name —'}
</p>
</div>
</div>
<!-- Right: Action buttons -->
<div class="flex flex-row gap-1 items-center shrink-0">
<!-- 1. Print Badge: Trusted+, not printed OR Edit Mode (reprint) -->
{#if is_trusted && (!is_printed || is_edit_mode)}
<a
href={`/events/${$lq__event_badge_obj.event_id}/badges/${$lq__event_badge_obj.event_badge_id}/print`}
class="btn btn-sm preset-tonal-primary flex items-center gap-1"
title={is_printed ? `Reprint badge (printed ${print_count}×)` : 'Print badge'}
>
<Printer size="1em" />
{#if is_printed}
<span class="font-bold text-xs">{print_count}×</span>
{/if}
<span class="hidden sm:inline">Print</span>
</a>
{/if}
<!-- 2. Copy review link to clipboard: Trusted + Edit Mode -->
{#if is_trusted && is_edit_mode}
<button
type="button"
class="btn btn-sm flex items-center gap-1"
class:preset-tonal-secondary={copy_status !== 'copied'}
class:preset-filled-success-500={copy_status === 'copied'}
onclick={() => copy_review_link()}
title="Copy review link to clipboard"
>
{#if copy_status === 'copied'}
<Check size="1em" />
<span class="hidden sm:inline">Copied!</span>
{:else}
<Link size="1em" />
<span class="hidden sm:inline">Copy Link</span>
{/if}
</button>
{/if}
<!-- 3. Email Review Link: all if not printed; Trusted+Edit if printed
TODO: replace alert with actual email API call -->
{#if !is_printed || (is_trusted && is_edit_mode)}
<button
type="button"
class="btn btn-sm preset-tonal-surface flex items-center gap-1"
onclick={() => send_review_email()}
title="Email a review link to this attendee"
>
<Mail size="1em" />
<span class="hidden sm:inline">Email Link</span>
</button>
{/if}
</div>
</header>
{#if has_any_access}
<div class="space-y-4">
<!-- Access level indicator -->
{#if is_administrator}
<p class="text-xs text-gray-400 flex items-center gap-1">
<ShieldCheck size="1em" class="text-purple-500" />
Administrator access — all fields editable
</p>
{:else if is_trusted}
<p class="text-xs text-gray-400 flex items-center gap-1">
<UserCheck size="1em" class="text-blue-500" />
Staff access — extended fields editable
</p>
{:else if has_attendee_access}
<p class="text-xs text-gray-400 flex items-center gap-1">
<User size="1em" class="text-green-500" />
Reviewing your badge information
</p>
{/if}
<Comp_badge_review_form
event_id={event_id as string}
event_badge_id={event_badge_id as string}
{lq__event_badge_obj}
{can_edit_fields}
is_staff={has_staff_access}
{log_lvl}
/>
</div>
{:else if !passcode_checked && !url_passcode}
<!-- Passcode entry (attendee navigates directly, no URL passcode) -->
<div class="card p-6 space-y-4 max-w-sm">
<h3 class="text-lg font-semibold">Enter Your Passcode</h3>
<p class="text-sm text-gray-500">
Enter the passcode from your badge review email to view and update your badge information.
</p>
<div class="space-y-2">
<label for="passcode-entry" class="block text-sm font-medium">Passcode</label>
<input
id="passcode-entry"
type="text"
class="input w-full font-mono tracking-widest"
bind:value={entered_passcode}
placeholder="Enter passcode"
onkeydown={(e) => { if (e.key === 'Enter') check_passcode(entered_passcode); }}
data-testid="badge-review-passcode-input"
/>
</div>
{#if passcode_error}
<p class="text-sm text-error-500">{passcode_error}</p>
{/if}
<button
type="button"
class="btn preset-filled-primary w-full"
onclick={() => check_passcode(entered_passcode)}
data-testid="badge-review-passcode-submit"
>
Access My Badge
</button>
</div>
{:else if passcode_checked && !passcode_valid}
<!-- Invalid passcode -->
<div class="card p-6 space-y-4 max-w-sm">
<div class="flex items-center gap-2 text-error-500">
<h3 class="text-lg font-semibold">Access Denied</h3>
</div>
<p class="text-sm text-gray-700">{passcode_error}</p>
<button
type="button"
class="btn preset-tonal-surface w-full"
onclick={() => { passcode_checked = false; passcode_error = ''; entered_passcode = ''; }}
>
Try Again
</button>
</div>
{/if}
{:else if is_loading_idb || !event_badge_id}
<div class="flex flex-col items-center justify-center p-20 gap-4 opacity-50">
<LoaderCircle size="3em" class="animate-spin" />
<p class="text-xl font-bold text-center">Loading Badge Information…</p>
</div>
{:else}
<div class="card p-8 text-center space-y-4">
<h2 class="text-2xl font-bold text-error-500">Badge Not Found</h2>
<p class="opacity-70">No record found for ID: <span class="font-mono">{event_badge_id}</span></p>
<button class="btn preset-filled-primary" onclick={() => window.location.reload()}>
Retry
</button>
</div>
{/if}

View File

@@ -0,0 +1,31 @@
/** @type {import('./$types').PageLoad} */
console.log(`Events - Badges [badge_id]/review +page.ts start`);
import { browser } from '$app/environment';
import { events_func } from '$lib/ae_events_functions';
// Loads badge + template for the review page (non-blocking background refresh)
export async function load({ params, parent }) {
const log_lvl: number = 0;
const parent_data = await parent();
const account_id = parent_data.account_id;
const ae_acct = parent_data[account_id];
if (browser) {
const event_id = params.event_id;
const event_badge_id = params.badge_id;
if (event_badge_id) {
events_func.load_ae_obj_id__event_badge({
api_cfg: ae_acct.api,
event_badge_id: event_badge_id,
event_id: event_id,
inc_template: false, // Review form doesn't need template
log_lvl: log_lvl
});
}
}
return { params };
}

View File

@@ -19,40 +19,93 @@
hide_badge_type = false
}: Props = $props();
// import { type Badge as BadgeType } from '$lib/ae_events/db_events';
import { ae_loc } from '$lib/stores/ae_stores';
import { events_loc } from '$lib/stores/ae_events_stores';
import { ae_util } from '$lib/ae_utils/ae_utils';
import {
LoaderCircle,
Badge,
Check,
Eye,
EyeOff,
Mail,
MapPin,
Tags,
FileSearch
FileSearch,
Link,
Printer,
User
} from 'lucide-svelte';
// Derived list of visible items (Standardized Pattern 2026-01-27)
// Track per-badge copy state for the "Review Link" clipboard button
let copy_status: Record<string, 'idle' | 'copied'> = $state({});
// Access level shortcuts
let is_trusted = $derived($ae_loc.trusted_access === true);
let is_edit_mode = $derived($ae_loc.edit_mode === true);
/**
* Obscures an email address for display to non-trusted users.
* e.g. john.doe@example.com → joh***@example.com
*/
function obscure_email(email: string | null | undefined): string {
if (!email) return '';
const at = email.indexOf('@');
if (at < 0) return email;
const visible = email.slice(0, Math.min(3, at));
return `${visible}***${email.slice(at)}`;
}
function build_review_url(event_badge_obj: any): string {
// TODO: append ?passcode=... when person_passcode is added to the event_badge schema
return `/events/${event_badge_obj.event_id}/badges/${event_badge_obj.event_badge_id}/review`;
}
async function copy_review_link(event_badge_obj: any) {
const url = build_review_url(event_badge_obj);
const full_url = window.location.origin + url;
try {
await navigator.clipboard.writeText(full_url);
copy_status[event_badge_obj.event_badge_id] = 'copied';
setTimeout(() => {
copy_status[event_badge_obj.event_badge_id] = 'idle';
}, 2000);
} catch {
console.error('Clipboard write failed');
}
}
// TODO: replace alert with actual email API call when available
function send_review_email(event_badge_obj: any) {
const name =
event_badge_obj?.full_name_override
?? event_badge_obj?.full_name
?? `${event_badge_obj?.given_name ?? ''} ${event_badge_obj?.family_name ?? ''}`.trim();
const email = is_trusted
? (event_badge_obj?.email ?? '(no email on file)')
: obscure_email(event_badge_obj?.email);
const event_name = $events_loc?.title ?? 'this event';
alert(`PLACEHOLDER: An email will be sent to ${name} at ${email}. Use that link to review your ${event_name} badge.`);
}
let visible_badge_obj_li = $derived(
(() => {
const list = $lq__event_badge_obj_li;
if (list === undefined || list === null) return null;
if (!Array.isArray(list)) return [];
const filtered = list.filter((item: any) => {
if (!item) return false;
// ADMIN/TRUSTED: See everything
if ($ae_loc.trusted_access) return true;
// PUBLIC: Filter hidden
return !item.hide;
if (is_trusted && is_edit_mode) {
// Edit Mode: show all non-hidden, including already-printed
return !item.hide;
}
// Everyone else (non-trusted or trusted not in edit mode):
// Only show badges that have not been printed yet
return (item.print_count ?? 0) < 1 && !item.hide;
});
if (log_lvl)
console.log(
`visible_badge_obj_li: Input=${list.length}, Output=${filtered.length}`
);
console.log(`visible_badge_obj_li: Input=${list.length}, Output=${filtered.length}`);
return filtered;
})()
);
@@ -67,21 +120,22 @@
<p>Loading badges...</p>
</div>
{:else if visible_badge_obj_li.length > 0}
<header
class="w-full flex flex-row gap-2 items-center justify-start mb-2 px-2"
>
<header class="w-full flex flex-row gap-2 items-center justify-start mb-2 px-2">
<h2 class="text-sm text-gray-500 font-normal">Results:</h2>
<span
class="badge preset-tonal-success font-bold text-lg px-3 py-1"
>
{visible_badge_obj_li.length}<span
class="text-gray-400 dark:text-gray-600">&times;</span
>
<span class="badge preset-tonal-success font-bold text-lg px-3 py-1">
{visible_badge_obj_li.length}<span class="text-gray-400 dark:text-gray-600">&times;</span>
</span>
</header>
<ul class="w-full space-y-1">
{#each visible_badge_obj_li as event_badge_obj (event_badge_obj.event_badge_id)}
{@const print_count = event_badge_obj.print_count ?? 0}
{@const is_printed = print_count >= 1}
{@const display_name =
event_badge_obj?.full_name_override
?? event_badge_obj?.full_name
?? `${event_badge_obj?.given_name ?? ''} ${event_badge_obj?.family_name ?? ''}`.trim()}
<li
class="
border border-surface-200 dark:border-surface-700 rounded-lg p-2
@@ -90,149 +144,157 @@
hover:border-primary-500 transition-colors
"
>
<div
class="flex flex-row flex-wrap gap-2 items-center justify-between w-full"
>
<div
class="flex flex-row flex-wrap gap-2 items-center justify-start grow"
>
<a
href={`/events/${event_badge_obj?.event_id}/badges/${event_badge_obj?.event_badge_id}`}
class="flex flex-row gap-2 items-center justify-start min-w-fit font-bold text-lg hover:text-primary-500"
>
<div class="flex flex-row flex-wrap gap-2 items-center justify-between w-full">
<!-- Left cluster: name (display only) + info chips -->
<div class="flex flex-row flex-wrap gap-x-3 gap-y-1 items-center grow min-w-0">
<!-- Name: always plain display — print action is a separate button -->
<span class="flex flex-row gap-1.5 items-center font-bold text-lg shrink-0">
{#if event_badge_obj?.hide}
<EyeOff
size="1.2em"
class="text-gray-400"
/>
<EyeOff size="1.1em" class="text-gray-400" />
{:else}
<Badge size="1.2em" />
<User size="1.1em" class="text-surface-400" />
{/if}
<span>{display_name}</span>
</span>
<span>
{#if event_badge_obj?.full_name_override}
{event_badge_obj?.full_name_override}
{:else if event_badge_obj?.full_name}
{event_badge_obj?.full_name}
{:else}
{event_badge_obj?.given_name}
{event_badge_obj?.family_name}
{/if}
</span>
{#if event_badge_obj?.print_count >= 1}
<span
class="badge preset-filled-success-500 flex items-center gap-1 text-xs py-0 px-1"
>
<Check size="1em" />
{event_badge_obj.print_count}
</span>
{/if}
</a>
{#if show_sensitive_fields}
<span
class="text-xs text-surface-400 flex items-center gap-1"
>
<!-- Email chip — obscured for non-trusted -->
{#if show_sensitive_fields && event_badge_obj?.email}
<span class="text-xs text-surface-400 flex items-center gap-1">
<Mail size="1em" />
{#if $ae_loc.trusted_access}
{event_badge_obj?.email}
{:else}
{event_badge_obj?.email?.replace(
/^(.{3}).*@/,
'$1...@'
) ?? ''}
{/if}
{is_trusted
? event_badge_obj.email
: obscure_email(event_badge_obj.email)}
</span>
{/if}
{#if !hide_affiliations && event_badge_obj.affiliations}
<span
class="text-xs text-surface-400 flex items-center gap-1"
>
<!-- Affiliations chip (prefers override) -->
{#if !hide_affiliations && (event_badge_obj?.affiliations_override ?? event_badge_obj?.affiliations)}
<span class="text-xs text-surface-400 flex items-center gap-1">
<MapPin size="1em" />
{event_badge_obj.affiliations}
{event_badge_obj.affiliations_override ?? event_badge_obj.affiliations}
</span>
{/if}
{#if !hide_badge_type && event_badge_obj.badge_type}
<span
class="text-xs italic text-primary-500 bg-primary-500/10 px-2 rounded-token flex items-center gap-1"
>
<!-- Badge type chip -->
{#if !hide_badge_type && event_badge_obj?.badge_type}
<span class="text-xs italic text-primary-500 bg-primary-500/10 px-2 rounded-token flex items-center gap-1">
<Tags size="1em" />
{event_badge_obj.badge_type}
</span>
{/if}
</div>
{#if $ae_loc.trusted_access}
<a
href={`/events/${event_badge_obj?.event_id}/badges/${event_badge_obj?.event_badge_id}#review`}
class="btn btn-sm preset-tonal-primary"
>
Review
</a>
{/if}
<!-- Right: up to 4 action buttons -->
<div class="flex flex-row gap-1 items-center shrink-0">
<!-- 1. Print Badge: Trusted+, not yet printed OR in Edit Mode (reprint) -->
{#if is_trusted && (!is_printed || is_edit_mode)}
<a
href={`/events/${event_badge_obj?.event_id}/badges/${event_badge_obj?.event_badge_id}/print`}
class="btn btn-sm preset-tonal-primary flex items-center gap-1"
title={is_printed ? `Reprint badge (printed ${print_count}×)` : 'Print badge'}
>
<Printer size="1em" />
{#if is_printed}
<span class="font-bold text-xs">{print_count}×</span>
{/if}
<span class="hidden sm:inline">Print</span>
</a>
{/if}
<!-- 2. Direct Review link: Trusted + Edit Mode (navigates to /review) -->
{#if is_trusted && is_edit_mode}
<a
href={build_review_url(event_badge_obj)}
class="btn btn-sm preset-tonal-secondary flex items-center gap-1"
title="Open badge review page"
>
<Eye size="1em" />
<span class="hidden sm:inline">Review</span>
</a>
{/if}
<!-- 3. Copy review link to clipboard: Trusted + Edit Mode -->
{#if is_trusted && is_edit_mode}
<button
type="button"
class="btn btn-sm flex items-center gap-1"
class:preset-tonal-secondary={copy_status[event_badge_obj.event_badge_id] !== 'copied'}
class:preset-filled-success-500={copy_status[event_badge_obj.event_badge_id] === 'copied'}
onclick={() => copy_review_link(event_badge_obj)}
title="Copy review link to clipboard"
>
{#if copy_status[event_badge_obj.event_badge_id] === 'copied'}
<Check size="1em" />
<span class="hidden sm:inline">Copied!</span>
{:else}
<Link size="1em" />
<span class="hidden sm:inline">Copy Link</span>
{/if}
</button>
{/if}
<!-- 4. Email Review Link: all if not printed; Trusted + Edit Mode if printed
TODO: replace alert with actual email API call -->
{#if !is_printed || (is_trusted && is_edit_mode)}
<button
type="button"
class="btn btn-sm preset-tonal-surface flex items-center gap-1"
onclick={() => send_review_email(event_badge_obj)}
title="Email a review link to this attendee"
>
<Mail size="1em" />
<span class="hidden sm:inline">Email Link</span>
</button>
{/if}
</div>
</div>
{#if $ae_loc.edit_mode}
<!-- Debug/metadata row — Edit Mode staff only -->
{#if is_edit_mode && is_trusted}
<div
class="flex flex-row flex-wrap gap-x-4 gap-y-1 items-center justify-start w-full mt-1 p-1.5 bg-surface-200/50 dark:bg-surface-800/50 rounded text-[10px] font-mono border border-surface-300 dark:border-surface-700 opacity-80"
>
<span class="flex items-center gap-1"
><span class="font-bold opacity-50">ID:</span>
{event_badge_obj?.event_badge_id}</span
>
<span class="flex items-center gap-1"
><span class="font-bold opacity-50">CR:</span>
{ae_util.iso_datetime_formatter(
event_badge_obj.created_on,
'datetime_iso_12_no_seconds'
)}</span
>
<span class="flex items-center gap-1"
><span class="font-bold opacity-50">UP:</span>
{ae_util.iso_datetime_formatter(
event_badge_obj.updated_on,
'datetime_iso_12_no_seconds'
)}</span
>
{#if event_badge_obj.print_first_datetime}
<span class="flex items-center gap-1"
><span class="font-bold opacity-50"
>FP:</span
>
{ae_util.iso_datetime_formatter(
event_badge_obj.print_first_datetime,
'datetime_iso_12_no_seconds'
)}</span
>
{/if}
{#if event_badge_obj.print_last_datetime}
<span class="flex items-center gap-1"
><span class="font-bold opacity-50"
>LP:</span
>
{ae_util.iso_datetime_formatter(
event_badge_obj.print_last_datetime,
'datetime_iso_12_no_seconds'
)}</span
>
{/if}
<span class="flex items-center gap-1">
<span class="font-bold opacity-50">ID:</span>
{event_badge_obj?.event_badge_id}
</span>
<span class="flex items-center gap-1">
<span class="font-bold opacity-50">CR:</span>
{ae_util.iso_datetime_formatter(event_badge_obj.created_on, 'datetime_iso_12_no_seconds')}
</span>
<span class="flex items-center gap-1">
<span class="font-bold opacity-50">UP:</span>
{ae_util.iso_datetime_formatter(event_badge_obj.updated_on, 'datetime_iso_12_no_seconds')}
</span>
<span class="flex items-center gap-1">
<span class="font-bold opacity-50">PC:</span>
{print_count}
</span>
<span class="flex items-center gap-1">
<span class="font-bold opacity-50">FP:</span>
{event_badge_obj.print_first_datetime
? ae_util.iso_datetime_formatter(event_badge_obj.print_first_datetime, 'datetime_iso_12_no_seconds')
: '—'}
</span>
<span class="flex items-center gap-1">
<span class="font-bold opacity-50">LP:</span>
{event_badge_obj.print_last_datetime
? ae_util.iso_datetime_formatter(event_badge_obj.print_last_datetime, 'datetime_iso_12_no_seconds')
: '—'}
</span>
</div>
{/if}
</li>
{/each}
</ul>
{:else}
<div
class="flex flex-col items-center justify-center p-20 opacity-50 text-center"
>
<div class="flex flex-col items-center justify-center p-20 opacity-50 text-center">
<FileSearch size="3em" class="mb-2 opacity-20 mx-auto" />
<p>
No badges found matching your criteria. Try adjusting your
filters.
</p>
<p>No badges found matching your criteria. Try adjusting your filters.</p>
</div>
{/if}
</section>