refactor: v2 badge render display-only + print button to controls panel

- ae_comp__badge_obj_view_v2.svelte: removed all inline edit-mode logic
  (floating Edit/Save/Cancel panel, placeholder list, input fields, save/cancel
  functions). V2 is now purely a display component — editing happens in the right
  panel (ae_comp__badge_print_controls.svelte) via liveQuery reactivity.
  display_* values are now $derived directly from lq__event_badge_obj.
  Fixes effective_badge_type_code CSS class (was always empty in previous v2).

- ae_comp__badge_print_controls.svelte: added "Print Badge" button at the top of
  the panel. Increments print_count, fires window.print(), then navigates to badge
  search. This is now the canonical print action for v2; the header "Print Now"
  button is a shortcut that calls window.print() only (no count tracking).
  Clarified $ae_loc.edit_mode comment — global AE Edit Mode is not a badge-specific
  edit toggle; used here only to gate reprints.

- print/+page.svelte: added clarifying comments on is_edit_mode (global vs local)
  and the header "Print Now" button (shortcut only, no count tracking).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-03-12 13:48:08 -04:00
parent 2198c55e27
commit c7063806b7
3 changed files with 270 additions and 691 deletions

View File

@@ -2,23 +2,19 @@
/** /**
* ae_comp__badge_obj_view_v2.svelte * ae_comp__badge_obj_view_v2.svelte
* *
* V2 badge render component — replaces the manual longest_str_part() heuristic * V2 badge render component — display-only. No inline edit mode.
* with element_fit_text.svelte (binary-search auto-scaling) for all four text
* fields (name, title, affiliations, location).
* *
* Auto-scaling is suppressed in edit mode (inputs need fixed size, not auto-scale). * Editing is handled entirely by the right-panel controls component
* Manual override props (font_size_*) from print controls still work — they are * (ae_comp__badge_print_controls.svelte). Changes saved there flow
* forwarded to Element_fit_text as manual_size, which disables auto-scale for * back via liveQuery (IDB → reactive UI update) automatically.
* that field and sets the size directly.
* *
* Differences from v1 (ae_comp__badge_obj_view.svelte): * Differences from v1 (ae_comp__badge_obj_view.svelte):
* - Imports Element_fit_text * - No inline edit mode — floating Edit/Save/Cancel panel removed
* - Removed: full_name_class_size, professional_title_class_size, * - No handle_save_changes / handle_cancel_changes / handle_print_badge
* affiliations_class_size, location_class_size state vars * - Print button lives in ae_comp__badge_print_controls.svelte
* - Removed: longest_str_part() function * - Uses Element_fit_text (binary-search auto-scaling) for all text fields
* - Removed: the $effect block that computed those size classes * - display_* values are $derived directly from lq__event_badge_obj
* - Text fields in display mode are wrapped with <Element_fit_text> * - Removed: longest_str_part() heuristic sizing
* - Edit mode uses plain divs (no auto-scaling on inputs)
*/ */
interface Props { interface Props {
@@ -26,37 +22,29 @@
event_badge_id: string; event_badge_id: string;
lq__event_badge_obj?: any; lq__event_badge_obj?: any;
lq__event_badge_template_obj?: any; lq__event_badge_template_obj?: any;
update_status?: string; /** Kept for API compatibility; unused in v2 (no inline edit mode). */
update_complete?: boolean;
is_review_mode?: boolean; is_review_mode?: boolean;
/** Optional px override for name font size. When undefined/null, auto-scaling runs. */ /** Optional px override for name font size. null/undefined auto-scaling runs. */
font_size_name?: number | null; font_size_name?: number | null;
/** Optional px override for professional title font size. When undefined/null, auto-scaling runs. */ /** Optional px override for professional title font size. */
font_size_title?: number | null; font_size_title?: number | null;
/** Optional px override for affiliations font size. When undefined/null, auto-scaling runs. */ /** Optional px override for affiliations font size. */
font_size_affiliations?: number | null; font_size_affiliations?: number | null;
/** Optional px override for location font size. When undefined/null, auto-scaling runs. */ /** Optional px override for location font size. */
font_size_location?: number | null; font_size_location?: number | null;
log_lvl?: number; log_lvl?: number;
} }
// event_id, is_review_mode, log_lvl are in Props for API compat (callers may pass them)
// but are not needed in this display-only component — omit from destructuring.
let { let {
event_id,
event_badge_id, event_badge_id,
lq__event_badge_obj, lq__event_badge_obj,
lq__event_badge_template_obj, lq__event_badge_template_obj,
update_status = $bindable('idle'),
update_complete = $bindable(true),
is_review_mode = false,
// V2: no numeric defaults — undefined → auto-scale runs via Element_fit_text.
// When the user adjusts a field in the print controls panel, a number is pushed
// here via the $bindable binding, which disables auto-scale for that field.
// Reset (×) in the print controls panel restores null → auto-scale resumes.
font_size_name, font_size_name,
font_size_title, font_size_title,
font_size_affiliations, font_size_affiliations,
font_size_location, font_size_location,
log_lvl = 0
}: Props = $props(); }: Props = $props();
// Badge layout CSS — compiled in, hot-reloads in dev. // Badge layout CSS — compiled in, hot-reloads in dev.
@@ -65,31 +53,15 @@
import '$lib/ae_events/badges/css/badge_layout_epson_4x5_fanfold.css'; import '$lib/ae_events/badges/css/badge_layout_epson_4x5_fanfold.css';
import '$lib/ae_events/badges/css/badge_layout_zebra_zc10l_pvc.css'; import '$lib/ae_events/badges/css/badge_layout_zebra_zc10l_pvc.css';
// *** Import Svelte specific
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import type { key_val } from '$lib/stores/ae_stores';
import { core_func } from '$lib/ae_core/ae_core_functions'; import { core_func } from '$lib/ae_core/ae_core_functions';
import { import { ae_loc, slct } from '$lib/stores/ae_stores';
ae_snip,
ae_loc,
ae_sess,
ae_api,
ae_trig,
slct,
slct_trigger
} from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events_functions';
// V2: auto-scaling text component // V2: auto-scaling text component
import Element_fit_text from '$lib/elements/element_fit_text.svelte'; import Element_fit_text from '$lib/elements/element_fit_text.svelte';
// *** Variables // --- Badge type list from template ---
let ae_triggers: key_val = $state({}); // Each item: { code: string, name: string }. Drives footer display + (in controls) dropdown.
// Badge type list is derived from the template — each event/template defines its own set.
// Falls back to empty array if template not loaded or badge_type_list is invalid JSON.
let badge_type_code_li = $derived.by(() => { let badge_type_code_li = $derived.by(() => {
const raw = $lq__event_badge_template_obj?.badge_type_list; const raw = $lq__event_badge_template_obj?.badge_type_list;
if (!raw) return []; if (!raw) return [];
@@ -101,33 +73,50 @@
} }
}); });
// Human-readable name for the current badge type, printed on the badge footer. // --- Effective display values (override ?? base) ---
// Priority: badge_type_override (staff custom name) → badge_type (base import name) // $derived keeps these reactive to liveQuery updates from the right panel saves.
// → template badge_type_list lookup by effective code → code itself as last resort. let display_name = $derived(
$lq__event_badge_obj?.full_name_override ?? $lq__event_badge_obj?.full_name ?? ''
);
let display_title = $derived(
$lq__event_badge_obj?.professional_title_override ?? $lq__event_badge_obj?.professional_title ?? ''
);
let display_affiliations = $derived(
$lq__event_badge_obj?.affiliations_override ?? $lq__event_badge_obj?.affiliations ?? ''
);
let display_location = $derived(
$lq__event_badge_obj?.location_override ?? $lq__event_badge_obj?.location ?? ''
);
// Effective badge type code — CSS class hook for per-event stylesheets.
// Priority: badge_type_code_override → badge_type_code
let effective_badge_type_code = $derived(
$lq__event_badge_obj?.badge_type_code_override ?? $lq__event_badge_obj?.badge_type_code ?? ''
);
// Human-readable badge type name — printed on the badge footer.
// Priority: badge_type_override (staff custom name) → badge_type (import name)
// → template list lookup by effective code → code itself as last resort.
// //
// Why separate from code: the code is the CSS class hook for per-event stylesheets. // A special-case attendee can share a code (and CSS styling) with "Member" but have a
// A special-case attendee can share a code (and thus styling) with "Member" but have a
// custom displayed name like "Life Member" stored in badge_type_override. // custom displayed name like "Life Member" stored in badge_type_override.
let badge_type_name = $derived.by(() => { let badge_type_name = $derived.by(() => {
// Staff override name takes priority
const override_name = $lq__event_badge_obj?.badge_type_override; const override_name = $lq__event_badge_obj?.badge_type_override;
if (override_name) return override_name; if (override_name) return override_name;
// Base name from import/registration system
const base_name = $lq__event_badge_obj?.badge_type; const base_name = $lq__event_badge_obj?.badge_type;
if (base_name) return base_name; if (base_name) return base_name;
// Fall back to template list lookup using the effective code
const found = (badge_type_code_li as { code: string; name: string }[]) const found = (badge_type_code_li as { code: string; name: string }[])
.find(item => item.code === editable_badge_type_code); .find(item => item.code === effective_badge_type_code);
return found?.name ?? editable_badge_type_code ?? ''; return found?.name ?? effective_badge_type_code ?? '';
}); });
// Show the badge back section when duplex=1 (or when duplex is not yet set, as a safe default). // Show badge back when duplex=1 (or not yet set safe default for existing templates).
// duplex=0/false → single-sided print (e.g. Zebra ZC10L PVC cards); back section is hidden. // duplex=0/false → single-sided (e.g. Zebra ZC10L PVC cards); back section is hidden.
let show_badge_back = $derived( let show_badge_back = $derived(
$lq__event_badge_template_obj?.duplex == null || !!$lq__event_badge_template_obj?.duplex $lq__event_badge_template_obj?.duplex == null || !!$lq__event_badge_template_obj?.duplex
); );
// Receipt and ticket sections are currently disabled pending redesign. // Receipt and ticket sections disabled pending redesign.
let show_receipt = $derived(false); let show_receipt = $derived(false);
let show_tickets = $derived(false); let show_tickets = $derived(false);
@@ -140,18 +129,13 @@
* name — height of the name Element_fit_text * name — height of the name Element_fit_text
* title — height of the title Element_fit_text * title — height of the title Element_fit_text
* grp_aff_loc — height of the affiliations_location container * grp_aff_loc — height of the affiliations_location container
* grp_aff_loc_flex — how affiliations vs location are distributed within that container * grp_aff_loc_flex — how affiliations vs location are distributed
* affiliations — height of the affiliations Element_fit_text * affiliations — height of the affiliations Element_fit_text
* location — height of the location Element_fit_text * location — height of the location Element_fit_text
* *
* Flex values: 'around' | 'between' | 'even' | 'center' | 'start' | 'end' * Flex values: 'around' | 'between' | 'even' | 'center' | 'start' | 'end'
* (mapped to space-around, space-between, space-evenly, center, flex-start, flex-end) * (mapped to space-around, space-between, space-evenly, center, flex-start, flex-end)
* *
* Layout codes (from ae_events/badges/css/):
* badge_3.5x5.5_pvc — Zebra ZC10L PVC card, 3.5" × 5.5", single-sided
* badge_4x5_fanfold — Epson 4" × 5" fanfold, duplex
* badge_4x6_fanfold — Default 4" × 6" fanfold, duplex
*
* TODO: Move to badge template config (cfg_json) once multi-layout events are needed. * TODO: Move to badge template config (cfg_json) once multi-layout events are needed.
* Tune these values with real badge data and a ruler. * Tune these values with real badge data and a ruler.
*/ */
@@ -212,84 +196,40 @@
return map[val] ?? 'space-around'; return map[val] ?? 'space-around';
} }
// *** Set initial variables // Sync selected badge ID to the slct store (used by other app modules for context).
$effect(() => { $effect(() => {
$slct.event_badge_id = event_badge_id; $slct.event_badge_id = event_badge_id;
}); });
let trigger = $state(null);
let initial_loading_promise = $state(null); // QR code
let hide_qr: null | boolean = $state(null);
let qr_data_url: any = $state('');
let qr_error_message = $state('');
let event_badge_obj_load_promise = $state(null);
let event_badge_obj_update_promise = $state(null);
let event_badge_qr_mecard_get_promise = $state(null);
let event_badge_qr_id_get_promise = $state(null);
let show_event_badge_tools_modal: boolean = $state(false);
let show_restricted_fields: boolean = $state(false);
// Editable fields
let editable_full_name_override: string | null = $state(null);
let editable_professional_title_override: string | null = $state(null);
let editable_affiliations_override: string | null = $state(null);
let editable_location_override: string | null = $state(null);
let editable_allow_tracking: boolean | null = $state(null);
let editable_email: string | null = $state(null);
let editable_badge_type_code: string | null = $state(null);
// Manage edit state locally
let edit_mode_active: boolean = $state(false);
// Initialize editable fields when lq__event_badge_obj changes
$effect(() => { $effect(() => {
if ($lq__event_badge_obj) { if (browser && $lq__event_badge_obj?.event_badge_id) {
if (log_lvl) { qr_error_message = '';
console.log('Initializing editable fields from lq__event_badge_obj'); qr_data_url = '';
} const params: any = {
editable_full_name_override = obj_type: 'event_badge',
$lq__event_badge_obj.full_name_override ?? obj_id: $lq__event_badge_obj.event_badge_id
$lq__event_badge_obj.full_name; };
editable_professional_title_override = try {
$lq__event_badge_obj.professional_title_override ?? qr_data_url = core_func.js_generate_qr_code('obj', params);
$lq__event_badge_obj.professional_title; } catch (error: any) {
editable_affiliations_override = qr_error_message = error?.message || 'Unknown error';
$lq__event_badge_obj.affiliations_override ?? console.error(error);
$lq__event_badge_obj.affiliations;
editable_location_override =
$lq__event_badge_obj.location_override ??
$lq__event_badge_obj.location;
editable_allow_tracking =
$lq__event_badge_obj.allow_tracking ?? null;
editable_email = $lq__event_badge_obj.email ?? null;
// Use staff override code if set; base code from import/registration otherwise.
editable_badge_type_code =
$lq__event_badge_obj.badge_type_code_override ??
$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;
} else {
edit_mode_active = false;
} }
} }
}); });
let show_print_msg: null | boolean = $state(null); /* *** BEGIN *** Legacy ticket/option state — move to template config in future */
let hide_qr: null | boolean = $state(null);
let use_badge_type_code = $state('');
let option_ticket_1_override = $state(''); let option_ticket_1_override = $state('');
let option_ticket_2_override = $state(''); let option_ticket_2_override = $state('');
let option_ticket_3_override = $state(''); let option_ticket_3_override = $state('');
let option_other_1_override = $state(''); let option_other_1_override = $state('');
let option_other_2_override = $state(''); let option_other_2_override = $state('');
let slct_badge_type = '';
/* *** BEGIN *** This should be moved out */
let option_ticket_1_display_opt = 'front_bool'; let option_ticket_1_display_opt = 'front_bool';
let option_ticket_2_display_opt = 'front_bool'; let option_ticket_2_display_opt = 'front_bool';
let option_ticket_3_display_opt = 'front_bool'; let option_ticket_3_display_opt = 'front_bool';
@@ -314,227 +254,16 @@
code_to_html.option_2['true'] = '<span class="fas fa-star-of-life"></span>'; code_to_html.option_2['true'] = '<span class="fas fa-star-of-life"></span>';
code_to_html.option_2['True'] = '<span class="fas fa-star-of-life"></span>'; code_to_html.option_2['True'] = '<span class="fas fa-star-of-life"></span>';
code_to_html.option_2['First Time '] = '<span class="fas fa-hand-paper"></span>'; code_to_html.option_2['First Time '] = '<span class="fas fa-hand-paper"></span>';
/* *** END *** This should be moved out */ /* *** END *** Legacy ticket/option state */
let qr_data_url: any = $state('');
let qr_error_message = $state('');
// Trigger doing a update for event badge
$effect(() => {
if (ae_triggers.event_badge_update) {
ae_triggers.event_badge_update = false;
update_status = 'loading';
update_complete = false;
}
});
$effect(() => {
if (browser && $lq__event_badge_obj?.event_badge_id) {
qr_error_message = '';
qr_data_url = '';
let params: any = {};
params.obj_type = 'event_badge';
params.obj_id = $lq__event_badge_obj?.event_badge_id;
try {
qr_data_url = core_func.js_generate_qr_code('obj', params);
} catch (error: any) {
qr_error_message = error?.message || 'Unknown error';
console.error(error);
}
}
});
// *** Functions and Logic
function prevent_default<T extends Event>(fn: (event: T) => void) {
return function (event: T) {
event.preventDefault();
fn(event);
};
}
async function handle_save_changes() {
if (!$lq__event_badge_obj?.event_badge_id) {
console.error('Cannot save changes: event_badge_id is missing.');
return;
}
update_status = 'loading';
update_complete = false;
const data_to_update: key_val = {};
// Only include fields that have actually changed
if (
editable_full_name_override !==
($lq__event_badge_obj.full_name_override ??
$lq__event_badge_obj.full_name)
) {
data_to_update.full_name_override = editable_full_name_override;
}
if (
editable_professional_title_override !==
($lq__event_badge_obj.professional_title_override ??
$lq__event_badge_obj.professional_title)
) {
data_to_update.professional_title_override =
editable_professional_title_override;
}
if (
editable_affiliations_override !==
($lq__event_badge_obj.affiliations_override ??
$lq__event_badge_obj.affiliations)
) {
data_to_update.affiliations_override =
editable_affiliations_override;
}
if (
editable_location_override !==
($lq__event_badge_obj.location_override ??
$lq__event_badge_obj.location)
) {
data_to_update.location_override = editable_location_override;
}
if (editable_allow_tracking !== $lq__event_badge_obj.allow_tracking) {
data_to_update.allow_tracking = editable_allow_tracking;
}
if (editable_email !== $lq__event_badge_obj.email) {
data_to_update.email = editable_email;
}
// Compare against the effective code (override ?? base) to detect a real change.
// Staff edits go to badge_type_code_override — the base import code is never overwritten.
const stored_effective_code =
$lq__event_badge_obj.badge_type_code_override ??
$lq__event_badge_obj.badge_type_code;
if (editable_badge_type_code !== stored_effective_code) {
data_to_update.badge_type_code_override = editable_badge_type_code;
// Keep badge_type_override in sync — look up the name from the template list.
// Edge case: if a badge needs a custom name that differs from the template list
// (e.g. "Life Member" for a "member" code), set badge_type_override manually
// in the DB. Do not use the dropdown — it will overwrite the custom name.
data_to_update.badge_type_override =
(badge_type_code_li as { code: string; name: string }[])
.find(item => item.code === editable_badge_type_code)?.name
?? editable_badge_type_code;
}
if (Object.keys(data_to_update).length === 0) {
console.log('No changes to save.');
update_status = 'done';
update_complete = true;
if (!is_review_mode) {
edit_mode_active = false;
}
return;
}
try {
await events_func.update_ae_obj__event_badge({
api_cfg: $ae_api,
event_id: event_id,
event_badge_id: $lq__event_badge_obj.event_badge_id,
data_kv: data_to_update,
log_lvl: log_lvl
});
update_status = 'done';
update_complete = true;
if (!is_review_mode) {
edit_mode_active = false;
}
} catch (error) {
console.error('Error saving changes:', error);
update_status = 'error';
update_complete = true;
}
}
function handle_cancel_changes() {
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_allow_tracking =
$lq__event_badge_obj.allow_tracking ?? null;
editable_email = $lq__event_badge_obj.email ?? null;
editable_badge_type_code =
$lq__event_badge_obj.badge_type_code_override ??
$lq__event_badge_obj.badge_type_code ?? null;
}
if (!is_review_mode) {
edit_mode_active = false;
}
update_status = 'idle';
update_complete = true;
}
let print_status = $state('idle'); // 'idle' | 'loading' | 'done' | 'error'
async function handle_print_badge() {
if (!$lq__event_badge_obj?.event_badge_id) {
console.error('Cannot print badge: event_badge_id is missing.');
return;
}
print_status = 'loading';
const now = new Date().toISOString();
const current_print_count = $lq__event_badge_obj.print_count ?? 0;
const is_first_print = current_print_count === 0;
const data_to_update: key_val = {
print_count: current_print_count + 1,
print_last_datetime: now
};
if (is_first_print) {
data_to_update.print_first_datetime = now;
}
try {
await events_func.update_ae_obj__event_badge({
api_cfg: $ae_api,
event_id: event_id,
event_badge_id: $lq__event_badge_obj.event_badge_id,
data_kv: data_to_update,
log_lvl: log_lvl
});
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`);
}, 1000);
} catch (error) {
console.error('Error printing badge:', error);
print_status = 'error';
setTimeout(() => {
print_status = 'idle';
}, 3000);
}
}
</script> </script>
<!-- Template debug info: screen-only. Reads from the template prop where $store subscription is reliable. --> <!-- Template debug info: screen-only. Shows layout context and v2 marker. -->
<div class="print:hidden flex items-center gap-2 text-xs text-gray-400 font-mono mb-1"> <div class="print:hidden flex items-center gap-2 text-xs text-gray-400 font-mono mb-1">
<span title="Badge template name">{$lq__event_badge_template_obj?.name ?? '—'}</span> <span title="Badge template name">{$lq__event_badge_template_obj?.name ?? '—'}</span>
<span class="text-gray-300">|</span> <span class="text-gray-300">|</span>
<span title="Layout code">{$lq__event_badge_template_obj?.layout ?? '(no layout)'}</span> <span title="Layout code">{$lq__event_badge_template_obj?.layout ?? '(no layout)'}</span>
<span class="text-gray-300">|</span> <span class="text-gray-300">|</span>
<span class="text-blue-400" title="V2 — auto-scaling text">v2</span> <span class="text-blue-400" title="V2 — auto-scaling text, display-only render">v2</span>
</div> </div>
<section <section
@@ -553,7 +282,7 @@
{#if $lq__event_badge_obj && $lq__event_badge_template_obj} {#if $lq__event_badge_obj && $lq__event_badge_template_obj}
<!-- *** badge_front section start *** --> <!-- *** badge_front section start *** -->
<section <section
class="badge_front badge_type__{use_badge_type_code.toLowerCase()} class="badge_front badge_type__{effective_badge_type_code.toLowerCase()}
flex flex-col gap-1 flex flex-col gap-1
items-stretch justify-between items-stretch justify-between
min-h-[6.0in] min-h-[6.0in]
@@ -578,160 +307,6 @@
Front of badge Front of badge
</span> </span>
<div
class="
print:hidden absolute top-1 right-4
hover:preset-tonal-secondary
transition-all group
flex flex-col gap-1 items-center justify-center
"
class:preset-outlined-warning-200-800={edit_mode_active}
class:preset-tonal-warning={edit_mode_active}
>
{#if edit_mode_active}
<button
type="button"
class="
btn btn-sm text-xs
preset-tonal-success preset-outlined-success-100-900 hover:preset-filled-success-500
transition-all group
"
onclick={handle_save_changes}
title="Save Changes"
data-testid="badge-save-btn"
>
<span class="fas fa-save m-1"></span>
<span
class="
hidden
group-hover:inline-block
text-xs
"
>
Save Changes
</span>
</button>
<button
type="button"
class="
btn btn-sm text-xs
preset-tonal-tertiary preset-outlined-tertiary-100-900 hover:preset-filled-tertiary-500
transition-all group
"
onclick={handle_cancel_changes}
title="Cancel Editing"
data-testid="badge-cancel-btn"
>
<span class="fas fa-times m-1"></span>
<span
class="
hidden
group-hover:inline-block
text-xs
"
>
Cancel
</span>
</button>
{:else}
<button
type="button"
class="
btn btn-sm text-xs
preset-tonal-warning preset-outlined-warning-100-900 hover:preset-filled-secondary-500
transition-all group
"
onclick={() => {
edit_mode_active = true;
}}
title="Edit Badge Information"
data-testid="badge-edit-btn"
>
<span class="fas fa-edit m-1"></span>
<span
class="
hidden
group-hover:inline-block
text-xs
"
>
Edit Badge Information
</span>
</button>
{/if}
<!-- Print Button -->
<button
type="button"
class="
btn btn-sm text-xs
preset-outlined-primary-100-900 hover:preset-filled-primary-500
transition-all group
"
class:preset-tonal-primary={print_status === 'loading'}
class:preset-filled-success-500={print_status === 'done'}
class:preset-filled-error-500={print_status === 'error'}
onclick={handle_print_badge}
disabled={print_status === 'loading'}
title="Print Badge (Increment Count)"
data-testid="badge-print-btn"
>
{#if print_status === 'loading'}
<span class="fas fa-spinner fa-spin m-1"></span>
{:else if print_status === 'done'}
<span class="fas fa-check m-1"></span>
{:else if print_status === 'error'}
<span class="fas fa-exclamation-triangle m-1"></span>
{:else}
<span class="fas fa-print m-1"></span>
{/if}
<span
class="
hidden
group-hover:inline-block
text-xs
"
>
{#if print_status === 'done'}
Printed!
{:else if print_status === 'error'}
Error
{:else}
Print Badge
{#if $lq__event_badge_obj?.print_count}
({$lq__event_badge_obj.print_count})
{/if}
{/if}
</span>
</button>
<div
class="w-md max-w-lg m-1 p-1"
class:hidden={!edit_mode_active}
>
<p class="text-xs italic text-gray-500">
Show list of fields that they can edit here. This may
need to broken down in to sections that can be
collapsed.
</p>
<ul class="text-left list-disc list-inside text-sm">
<li>Full Name</li>
<li>Professional Title</li>
<li>Affiliations</li>
<li>Location</li>
<li>Option Ticket 1</li>
<li>Option Ticket 2</li>
<li>Option Ticket 3</li>
<li>Option Other 1</li>
<li>Option Other 2</li>
<li>Badge Type</li>
<li>Allow Tracking</li>
<li>Show Print Message</li>
<li>Hide QR Code</li>
</ul>
</div>
</div>
{#if $lq__event_badge_template_obj.header_path} {#if $lq__event_badge_template_obj.header_path}
<div <div
class="badge_header class="badge_header
@@ -777,6 +352,11 @@
items-stretch justify-between items-stretch justify-between
" "
> >
<!--
person_name container: explicit height from fit_heights so Element_fit_text
can measure overflow correctly. flex-col with justify-content distributes
name and title vertically within the constrained space.
-->
<div <div
class="person_name class="person_name
m-0 p-0 m-0 p-0
@@ -787,21 +367,12 @@
style="height: {fit_heights.grp_name_title}; justify-content: {flex_justify(fit_heights.grp_name_title_flex)}" style="height: {fit_heights.grp_name_title}; justify-content: {flex_justify(fit_heights.grp_name_title_flex)}"
> >
<!-- <!--
V2: Element_fit_text wraps display mode only. V2: Element_fit_text wraps display mode only — no inline edit inputs.
Edit mode uses a plain div so inputs are not auto-scaled. manual_size={font_size_name ?? null} disables auto-scaling when the
manual_size={font_size_name ?? null} disables auto-scaling print-controls panel sets a fixed size. Reset in controls → null → auto resumes.
when the print-controls panel is setting a fixed size. Bounds: min=36 (readable for short names at small containers)
Bounds: min=20 (readable at small sizes) max=80 (fills badge width for short names). max=80 (fills badge width for short names like "Bob")
--> -->
{#if edit_mode_active}
<div class="full_name_override_all leading-none">
<input
type="text"
bind:value={editable_full_name_override}
class="input w-full text-center"
/>
</div>
{:else}
<Element_fit_text <Element_fit_text
min={36} min={36}
max={80} max={80}
@@ -810,29 +381,19 @@
class="full_name_override_all hover:bg-pink-100/50" class="full_name_override_all hover:bg-pink-100/50"
> >
<span class="full_name_override"> <span class="full_name_override">
{#if editable_full_name_override} {#if display_name}
{@html editable_full_name_override.trim()} {@html display_name.trim()}
{:else} {:else}
-- no name -- -- no name --
{/if} {/if}
</span> </span>
</Element_fit_text> </Element_fit_text>
{/if}
{#if editable_professional_title_override || edit_mode_active} {#if display_title}
<!-- <!--
Bounds: min=14 (small italic fine at small sizes) max=38 (fills badge width for short titles). Bounds: min=18 (small italic fine at small sizes)
max=38 (fills badge width for short titles)
--> -->
{#if edit_mode_active}
<div class="professional_title leading-none italic">
<input
type="text"
bind:value={editable_professional_title_override}
class="input w-full text-center"
data-testid="badge-professional-title-input"
/>
</div>
{:else}
<Element_fit_text <Element_fit_text
min={18} min={18}
max={38} max={38}
@@ -840,13 +401,16 @@
height={fit_heights.title} height={fit_heights.title}
class="professional_title italic hover:bg-pink-100/50" class="professional_title italic hover:bg-pink-100/50"
> >
{@html editable_professional_title_override} {@html display_title}
</Element_fit_text> </Element_fit_text>
{/if} {/if}
{/if}
</div> </div>
{#if editable_affiliations_override || editable_location_override || edit_mode_active || editable_email || edit_mode_active || editable_allow_tracking !== null || edit_mode_active} {#if display_affiliations || display_location}
<!--
affiliations_location container: explicit height from fit_heights.
flex-col with justify-content distributes affiliations and location.
-->
<div <div
class="affiliations_location class="affiliations_location
m-0 p-0 m-0 p-0
@@ -857,19 +421,11 @@
" "
style="height: {fit_heights.grp_aff_loc}; justify-content: {flex_justify(fit_heights.grp_aff_loc_flex)}" style="height: {fit_heights.grp_aff_loc}; justify-content: {flex_justify(fit_heights.grp_aff_loc_flex)}"
> >
{#if editable_affiliations_override || edit_mode_active} {#if display_affiliations}
<!-- <!--
Bounds: min=12 (multi-line affiliations can be small) max=40 (fills badge for short org names). Bounds: min=18 (multi-line affiliations can be small)
max=40 (fills badge for short org names)
--> -->
{#if edit_mode_active}
<div class="affiliations leading-none">
<textarea
bind:value={editable_affiliations_override}
class="textarea w-full text-center"
rows="2"
></textarea>
</div>
{:else}
<Element_fit_text <Element_fit_text
min={18} min={18}
max={40} max={40}
@@ -877,24 +433,15 @@
height={fit_heights.affiliations} height={fit_heights.affiliations}
class="affiliations hover:bg-pink-100/50" class="affiliations hover:bg-pink-100/50"
> >
{@html editable_affiliations_override} {@html display_affiliations}
</Element_fit_text> </Element_fit_text>
{/if} {/if}
{/if}
{#if editable_location_override || edit_mode_active} {#if display_location}
<!-- <!--
Bounds: min=12 (long city/country strings) max=34 (matches title upper bound). Bounds: min=18 (long city/country strings)
max=34 (matches title upper bound)
--> -->
{#if edit_mode_active}
<div class="location leading-none">
<input
type="text"
bind:value={editable_location_override}
class="input w-full text-center"
/>
</div>
{:else}
<Element_fit_text <Element_fit_text
min={18} min={18}
max={34} max={34}
@@ -903,37 +450,14 @@
class="location hover:bg-pink-100/50" class="location hover:bg-pink-100/50"
> >
<span class="city state_province country" <span class="city state_province country"
>{@html editable_location_override}</span >{@html display_location}</span
> >
</Element_fit_text> </Element_fit_text>
{/if} {/if}
{/if}
{#if edit_mode_active}
<!-- Email: editable in review/edit mode only — never printed on badge -->
<div class="email-field text-sm">
<label>Email: <input
type="email"
bind:value={editable_email}
class="input w-full"
/></label>
</div> </div>
{/if} {/if}
{#if edit_mode_active} {#if ['front_bool', 'front_back_bool'].includes(option_ticket_1_display_opt) || ['front_bool', 'front_back_bool'].includes(option_ticket_2_display_opt) || ['front_bool', 'front_back_bool'].includes(option_ticket_3_display_opt) || $lq__event_badge_template_obj?.show_qr_front}
<!-- Allow tracking: editable in review/edit mode only — never printed on badge -->
<div class="allow-tracking-field text-sm flex items-center justify-center gap-2">
<label>Allow Tracking: <input
type="checkbox"
bind:checked={editable_allow_tracking}
class="checkbox"
/></label>
</div>
{/if}
</div>
{/if}
{#if ['front_bool', 'front_back_bool'].includes(option_ticket_1_display_opt) || ['front_bool', 'front_back_bool'].includes(option_ticket_2_display_opt) || ['front_bool', 'front_back_bool'].includes(option_ticket_3_display_opt) || $lq__event_badge_template_obj?.show_qr_front || edit_mode_active}
<!-- flex-col so ticket icons row and QR stack vertically; QR is centered --> <!-- flex-col so ticket icons row and QR stack vertically; QR is centered -->
<div class="special flex flex-col items-center w-full"> <div class="special flex flex-col items-center w-full">
<div class="flex flex-row justify-between w-full"> <div class="flex flex-row justify-between w-full">
@@ -972,7 +496,7 @@
<div <div
class="badge_footer class="badge_footer
{editable_badge_type_code?.toLowerCase()} {effective_badge_type_code.toLowerCase()}
justify-self-end justify-self-end
max-h-[.50in] max-h-[.50in]
max-w-full max-w-full
@@ -981,23 +505,8 @@
flex flex-row gap-1 items-center justify-center flex flex-row gap-1 items-center justify-center
hover:outline-2 hover:outline-dashed hover:outline-gray-500/75 hover:outline-2 hover:outline-dashed hover:outline-gray-500/75
" "
title={editable_badge_type_code} title={effective_badge_type_code}
> >
{#if edit_mode_active && badge_type_code_li.length > 0}
<label
>Badge Type:
<select
bind:value={editable_badge_type_code}
class="select text-xs px-1 max-w-fit"
>
{#each badge_type_code_li as badge_type_code_item (badge_type_code_item.code)}
<option value={badge_type_code_item.code}
>{badge_type_code_item.name}</option
>
{/each}
</select>
</label>
{:else}
{#if option_other_1_override && ['front_bool', 'front_back_bool'].includes(option_other_1_display_opt)} {#if option_other_1_override && ['front_bool', 'front_back_bool'].includes(option_other_1_display_opt)}
<span class="badge_footer_special_left" <span class="badge_footer_special_left"
><span class="fas fa-biohazard"></span></span ><span class="fas fa-biohazard"></span></span
@@ -1011,7 +520,7 @@
<!-- badge_type_code is the CSS class hook for per-event stylesheets; <!-- badge_type_code is the CSS class hook for per-event stylesheets;
badge_type_name is what's actually printed on the badge. --> badge_type_name is what's actually printed on the badge. -->
<span <span
class="badge_footer_center {editable_badge_type_code?.toLowerCase()}" class="badge_footer_center {effective_badge_type_code.toLowerCase()}"
>{badge_type_name}</span >{badge_type_name}</span
> >
@@ -1024,7 +533,6 @@
>{@html option_other_2_override}</span >{@html option_other_2_override}</span
> >
{/if} {/if}
{/if}
</div> </div>
<!-- badge class div end --> <!-- badge class div end -->
@@ -1238,36 +746,6 @@
</div> </div>
{/if} {/if}
{#if $lq__event_badge_template_obj.exhibitor_info}
<div class="container exhibitor_information">
<strong>Exhibitor Info:</strong>
</div>
{/if}
{#if $lq__event_badge_template_obj.presenter_info}
<div class="container presenter_information">
<strong>Presenter Info:</strong>
</div>
{/if}
{#if $lq__event_badge_template_obj.staff_info}
<div class="container staff_information">
<strong>Staff Info:</strong>
</div>
{/if}
{#if $lq__event_badge_template_obj.vip_info}
<div class="container vip_information">
<strong>VIP Info:</strong>
</div>
{/if}
{#if $lq__event_badge_template_obj.vote_info}
<div class="container vote_information">
<div><strong>Voting Info:</strong></div>
</div>
{/if}
{#if $lq__event_badge_template_obj.show_qr_back} {#if $lq__event_badge_template_obj.show_qr_back}
<div <div
class="person_information qr_badge_id class="person_information qr_badge_id
@@ -1448,7 +926,7 @@
{/if} {/if}
<!-- *** ticket section end *** --> <!-- *** ticket section end *** -->
{/if} {/if}
<!-- End if for $lq__event_badge_template_obj --> <!-- End if for lq__event_badge_obj && lq__event_badge_template_obj -->
</section> </section>
<div> <div>

View File

@@ -18,7 +18,8 @@
import type { key_val } from '$lib/stores/ae_stores'; import type { key_val } from '$lib/stores/ae_stores';
import { ae_api, ae_loc } from '$lib/stores/ae_stores'; import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { events_func } from '$lib/ae_events_functions'; import { events_func } from '$lib/ae_events_functions';
import { Pencil, Check, X, LoaderCircle, ChevronDown } from 'lucide-svelte'; import { browser } from '$app/environment';
import { Pencil, Check, X, LoaderCircle, ChevronDown, Printer } from 'lucide-svelte';
interface Props { interface Props {
event_id: string; event_id: string;
@@ -55,6 +56,58 @@
// professional_title_override, affiliations_override, and location_override. // professional_title_override, affiliations_override, and location_override.
let is_trusted = $derived($ae_loc.trusted_access === true); let is_trusted = $derived($ae_loc.trusted_access === true);
// IMPORTANT: $ae_loc.edit_mode is the GLOBAL AE Edit Mode — a UI preference that
// reveals editable fields, debug info, and advanced options across the whole app.
// It is NOT the same as the badge-specific edit state (which no longer exists in
// the v2 badge render — all badge editing happens here in the controls panel).
// This component NEVER writes to $ae_loc.edit_mode. Read-only usage only:
// — used here to allow reprinting an already-printed badge when global edit mode is active.
let is_global_edit_mode = $derived($ae_loc.edit_mode === true);
// --- Print ---
let print_count = $derived($lq__event_badge_obj?.print_count ?? 0);
let is_printed = $derived(print_count >= 1);
// Print is available to Trusted+ when: not yet printed, OR global edit mode is on (reprint).
let can_print = $derived(is_trusted && (!is_printed || is_global_edit_mode));
type PrintStatus = 'idle' | 'loading' | 'done' | 'error';
let print_status: PrintStatus = $state('idle');
async function handle_print_badge() {
if (!$lq__event_badge_obj?.event_badge_id) return;
print_status = 'loading';
const now = new Date().toISOString();
const is_first_print = print_count === 0;
const data_to_update: key_val = {
print_count: print_count + 1,
print_last_datetime: now
};
if (is_first_print) data_to_update.print_first_datetime = now;
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
});
print_status = 'done';
// Trigger browser print dialog after count is recorded
if (browser) window.print();
// Brief success flash, then return to badge search
await new Promise<void>(r => setTimeout(r, 1000));
// Full navigation back to badge search — avoids goto() lint rule in child components
if (browser) window.location.href = `/events/${event_id}/badges`;
} catch (err) {
console.error('Badge print controls: print error:', err);
print_status = 'error';
setTimeout(() => { print_status = 'idle'; }, 3000);
}
}
// --- Badge type list from template --- // --- Badge type list from template ---
// Each item: { code: string, name: string }. Used for the badge type dropdown. // Each item: { code: string, name: string }. Used for the badge type dropdown.
let badge_type_code_li = $derived.by((): { code: string; name: string }[] => { let badge_type_code_li = $derived.by((): { code: string; name: string }[] => {
@@ -265,6 +318,45 @@
<!-- ============================================================ <!-- ============================================================
Main panel Main panel
============================================================ --> ============================================================ -->
<!-- Print button — canonical print action for v2. Increments print_count, fires
window.print(), then navigates back to badge search. Only shown to Trusted+
when not yet printed, OR when global AE Edit Mode is active (allows reprints).
The header "Print Now" button is a shortcut that calls window.print() only —
it does NOT track print count. Always use this button for the official print. -->
{#if can_print}
<div class="mb-3 pb-3 border-b border-gray-200 dark:border-gray-700">
<button
type="button"
class="btn btn-sm w-full flex items-center justify-center gap-2"
class:preset-filled-primary={print_status === 'idle'}
class:preset-tonal-surface={print_status === 'loading'}
class:preset-filled-success={print_status === 'done'}
class:preset-tonal-error={print_status === 'error'}
onclick={handle_print_badge}
disabled={print_status === 'loading' || print_status === 'done'}
title={is_printed ? `Reprint badge (printed ${print_count}×)` : 'Print badge now'}
data-testid="badge-print-btn"
>
{#if print_status === 'loading'}
<LoaderCircle size="14" class="animate-spin" /> Printing…
{:else if print_status === 'done'}
<Check size="14" /> Printed!
{:else if print_status === 'error'}
Error — try again
{:else}
<Printer size="14" />
{is_printed ? `Reprint (${print_count}×)` : 'Print Badge'}
{/if}
</button>
{#if is_printed && print_status === 'idle'}
<p class="text-[10px] text-amber-600 dark:text-amber-400 text-center mt-1">
Already printed {print_count}×
</p>
{/if}
</div>
{/if}
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<!-- === NAME === --> <!-- === NAME === -->

View File

@@ -52,6 +52,11 @@
// Access level shortcuts // Access level shortcuts
let is_trusted = $derived($ae_loc.trusted_access === true); let is_trusted = $derived($ae_loc.trusted_access === true);
// IMPORTANT: $ae_loc.edit_mode is the GLOBAL AE Edit Mode — a UI preference that
// reveals editable fields, debug info, and advanced options app-wide. It is NOT
// a badge-specific edit toggle. Never write to it from badge components.
// Used here only to gate the header "Print Now" shortcut (allow reprints in edit mode).
let is_edit_mode = $derived($ae_loc.edit_mode === true); let is_edit_mode = $derived($ae_loc.edit_mode === true);
// Print state derived from badge // Print state derived from badge
@@ -186,7 +191,11 @@
{use_v2 ? 'v2' : 'v1'} {use_v2 ? 'v2' : 'v1'}
</button> </button>
<!-- 1. Print Now: Trusted+, not printed OR Edit Mode (reprint) --> <!-- 1. Print Now (header shortcut): calls window.print() only — does NOT track
print_count. The canonical "Print Badge" button in the right panel controls
increments the count, fires window.print(), then redirects to badge search.
Use this header button only as a quick re-trigger of the print dialog. -->
<!-- Trusted+, not printed OR global Edit Mode active (reprint) -->
{#if is_trusted && (!is_printed || is_edit_mode)} {#if is_trusted && (!is_printed || is_edit_mode)}
<button <button
type="button" type="button"