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,3 +1,6 @@
# Aether (AE) Event Badges Module (v3)
# Aether (AE) Events - Badges Module (v3)
This directory contains the files for the new Event Badges module (v3). Detailed documentation to follow.
This directory contains the files for the new Event Badges module (v3).
Detailed documentation can be found here:
@documentation/MODULE__AE_Events_Badges.md

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>

View File

@@ -92,12 +92,12 @@
{/each}
</div>
{#if $ae_loc.administrator_access}
{#if $ae_loc.administrator_access && $ae_loc.edit_mode}
<section class="card p-6 variant-soft-warning border-l-4 border-warning-500 mt-12 bg-surface-100 dark:bg-surface-800 shadow-lg">
<div class="flex items-center gap-4">
<span class="fas fa-tools text-3xl"></span>
<div>
<h3 class="text-xl font-bold">Administrative Access</h3>
<h3 class="text-xl font-bold">Event Admin Settings</h3>
<p>You have elevated privileges. Use the menu above to access advanced settings and reports.</p>
</div>
<a href="/events/{$events_slct.event_id}/settings" class="btn ae_btn_warning ml-auto">

View File

@@ -38,7 +38,7 @@
let show_upload_badge_modal: boolean = $state(false);
// Guard: Only allow administrators in edit mode
if (!$ae_loc.administrator_access || !$ae_loc.edit_mode) {
if (!$ae_loc.administrator_access) {
if (browser) {
alert(
'Access Denied: Administrative privileges and Edit Mode required.'

View File

@@ -8,6 +8,73 @@
let { mod_badges_json = $bindable({}), onsave }: Props = $props();
/**
* edit_permissions — controls which fields each access level may edit in the badge review form.
* Stored as mod_badges_json.edit_permissions.
*
* Structure:
* authenticated.can_edit — fields attendees (passcode-validated) may edit
* trusted.can_edit — fields trusted staff may edit
* administrator.can_edit — '*' (all) or a specific field list
*
* Default attendee fields: full_name_override, professional_title_override,
* affiliations_override, location_override
* Default trusted fields: above + email, badge_type_code
*/
const all_attendee_fields = [
{ key: 'full_name_override', label: 'Full Name (override)' },
{ key: 'professional_title_override', label: 'Professional Title (override)' },
{ key: 'affiliations_override', label: 'Affiliations (override)' },
{ key: 'location_override', label: 'Location (override)' }
];
const all_staff_fields = [
...all_attendee_fields,
{ key: 'email', label: 'Email' },
{ key: 'badge_type_code', label: 'Badge Type Code' }
];
// Ensure edit_permissions sub-object exists
function ensure_permissions() {
if (!mod_badges_json) return;
if (!mod_badges_json.edit_permissions) {
mod_badges_json.edit_permissions = {};
}
if (!mod_badges_json.edit_permissions.authenticated) {
mod_badges_json.edit_permissions.authenticated = {
can_edit: ['full_name_override', 'professional_title_override', 'affiliations_override', 'location_override']
};
}
if (!mod_badges_json.edit_permissions.trusted) {
mod_badges_json.edit_permissions.trusted = {
can_edit: ['full_name_override', 'professional_title_override', 'affiliations_override', 'location_override', 'email', 'badge_type_code']
};
}
if (!mod_badges_json.edit_permissions.administrator) {
mod_badges_json.edit_permissions.administrator = { can_edit: '*' };
}
}
function is_field_enabled(level: 'authenticated' | 'trusted', field_key: string): boolean {
const cfg = mod_badges_json?.edit_permissions?.[level]?.can_edit;
if (!cfg) return false;
if (cfg === '*') return true;
return Array.isArray(cfg) && cfg.includes(field_key);
}
function toggle_field(level: 'authenticated' | 'trusted', field_key: string) {
ensure_permissions();
if (!mod_badges_json?.edit_permissions?.[level]) return;
let fields: string[] = mod_badges_json.edit_permissions[level].can_edit;
if (!Array.isArray(fields)) fields = [];
if (fields.includes(field_key)) {
mod_badges_json.edit_permissions[level].can_edit = fields.filter((f: string) => f !== field_key);
} else {
mod_badges_json.edit_permissions[level].can_edit = [...fields, field_key];
}
}
function save() {
if (onsave && mod_badges_json) onsave(mod_badges_json);
}
@@ -100,7 +167,61 @@
</label>
</div>
</div>
{/if}
<details class="space-y-3">
<summary class="cursor-pointer font-medium text-sm">
Badge Review — Editable Field Permissions
</summary>
<div class="space-y-4 pt-2 pl-2">
<p class="text-xs text-gray-500">
Controls which fields each access level may edit on the Badge Review page.
Staff (Trusted) defaults include all attendee fields plus Email and Badge Type Code.
Administrators can always edit everything.
</p>
<!-- Attendee (passcode-validated) -->
<div class="space-y-1">
<p class="text-sm font-medium">Attendees (passcode link)</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
{#each all_attendee_fields as field}
<label class="label flex items-center gap-2 text-sm">
<input
type="checkbox"
class="checkbox"
checked={is_field_enabled('authenticated', field.key)}
onchange={() => toggle_field('authenticated', field.key)}
/>
<span>{field.label}</span>
</label>
{/each}
</div>
</div>
<!-- Staff (Trusted) -->
<div class="space-y-1">
<p class="text-sm font-medium">Staff (Trusted access)</p>
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
{#each all_staff_fields as field}
<label class="label flex items-center gap-2 text-sm">
<input
type="checkbox"
class="checkbox"
checked={is_field_enabled('trusted', field.key)}
onchange={() => toggle_field('trusted', field.key)}
/>
<span>{field.label}</span>
</label>
{/each}
</div>
</div>
<!-- Administrator note -->
<p class="text-xs text-gray-400 italic">
Administrators always have access to all fields (not configurable).
</p>
</div>
</details>
{/if} <!-- end {#if mod_badges_json} -->
<button type="button" class="btn preset-tonal-primary" onclick={save}
>Save</button