From 0199c2e2c96cbf0debf2d6d5f90d5a83c47b81ac Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 14 Apr 2026 17:07:26 -0400 Subject: [PATCH] =?UTF-8?q?fix(badges):=20guard=20unsaved=20edits=20?= =?UTF-8?q?=E2=80=94=20warn=20on=20close,=20error=20on=20second=20close?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a field accordion has unsaved changes and the user tries to close (X button, same-header click, or switching to another field), we now set pending_close = true instead of silently discarding. - Save button turns bright red + animate-pulse with label "Save first (or × to discard)" - X button turns red with "Discard changes" tooltip - Field stays open — no data is lost - Second close attempt (pending_close already true) actually discards - Saving normally clears pending_close and closes the accordion WHY: kiosk attendees at a live event were silently losing typed overrides (professional title, affiliations, etc.) when switching fields mid-queue. Co-Authored-By: Claude Sonnet 4.6 --- .../ae_comp__badge_print_controls.svelte | 94 +++++++++++++++---- 1 file changed, 75 insertions(+), 19 deletions(-) diff --git a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_print_controls.svelte b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_print_controls.svelte index 822c54d7..11451182 100644 --- a/src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_print_controls.svelte +++ b/src/routes/events/[event_id]/(badges)/badges/[badge_id]/ae_comp__badge_print_controls.svelte @@ -341,20 +341,53 @@ function font_size_reset( // --- Accordion: one field section open at a time --- let active_field: string | null = $state(null); +// --- Unsaved-change guard --- +// When the user tries to close or switch away from a dirty field, we DON'T +// silently discard. Instead we set pending_close = true, which turns the Save +// button bright red + pulsing so they notice they have unsaved changes. +// A second close attempt (while pending_close is already true) actually discards. +// WHY: kiosk attendees at a conference are confused when changes disappear without +// warning; they can't lose a professional title or affiliation mid-queue. +let pending_close = $state(false); + +/** Returns the dirty state for the given field key. */ +function is_dirty_for_field(field_key: string): boolean { + switch (field_key) { + case 'name': return is_dirty_name; + case 'title': return is_dirty_title; + case 'affiliations': return is_dirty_affiliations; + case 'location': return is_dirty_location; + case 'pronouns': return is_dirty_pronouns; + case 'allow_tracking': return is_dirty_allow_tracking; + case 'badge_type': return is_dirty_badge_type; + default: return false; + } +} + function toggle_field(key: string) { - // Capture whether the caller is closing the field that's already open. + // Capture whether the caller is re-clicking the field that's already open. const was_open = active_field === key; - // Always cancel the current field before switching — discards any unsaved edit - // state so stale typed values don't bleed into the badge preview after the - // accordion closes. The explicit [X] button calls cancel_field directly; - // clicking away to another field (or re-clicking the pencil) must do the same. - if (active_field) { - cancel_field(active_field); // resets edit vars + sets active_field = null + + if (active_field && !was_open) { + // User is trying to switch to a different field. + if (is_dirty_for_field(active_field) && !pending_close) { + // Dirty — warn instead of switching. The Save button turns red so + // the user knows they need to save (or cancel) the current field first. + pending_close = true; + return; // block the switch + } + // Either clean, or second attempt after warning — discard and switch. + cancel_field(active_field); } - if (!was_open) { - active_field = key; // open the new field + + if (was_open) { + // Clicking the same field header again: same guard as the X button. + cancel_field(key); + return; } - // If was_open: active_field stays null — the field is now closed. + + pending_close = false; + active_field = key; } // --- Editable field state --- @@ -416,6 +449,7 @@ async function save_field(field_key: string, data_kv: key_val) { log_lvl }); field_save_status = { ...field_save_status, [field_key]: 'done' }; + pending_close = false; // liveQuery auto-refreshes the badge; close the accordion after a brief confirmation. setTimeout(() => { field_save_status = { ...field_save_status, [field_key]: 'idle' }; @@ -434,6 +468,15 @@ async function save_field(field_key: string, data_kv: key_val) { } function cancel_field(field_key: string) { + // First cancel attempt while dirty → warn, don't discard. + // Second attempt (pending_close already set) → actually discard. + if (is_dirty_for_field(field_key) && !pending_close) { + pending_close = true; + return; + } + + pending_close = false; + if (!$lq__event_badge_obj) { active_field = null; return; @@ -888,41 +931,54 @@ let allow_tracking_open = $derived( title="Remove override — restore original imported value" aria-label="Revert to original value" aria-hidden={!on_revert}> - + - + + title={pending_close ? 'Discard changes' : 'Cancel'} + aria-label={pending_close ? 'Discard changes' : 'Cancel editing'}> + + {/snippet}