feat(badges): public_access kiosk mode + manager access improvements

- Public (attendee) kiosk: unprinted badges link to /review; printed
  badges show green "Checked in · Nx · First/Last" row (non-clickable)
- Public attendees no longer see Print button (staff-only action)
- Printed badges sort to end of list for public non-trusted users
- Manager access: Print (reprint) and Email Link buttons always visible
  without requiring Edit Mode; main row behavior unchanged
- Empty state wording: context-aware — "Enter your name above to find
  your badge" for public users with no query vs "No badges found" after
  an actual search
- Docs: Epson C3500 fanfold section filled in (was empty placeholder);
  style_href/duplex implementation status corrected in badge templates
  doc; Axonius C3500 layout TODO marked complete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-02 16:15:08 -04:00
parent 72c8f9b502
commit 60bdd2fdba
5 changed files with 112 additions and 42 deletions

View File

@@ -157,26 +157,58 @@ This layout hides `.badge_back` in `@media print` — only the front face prints
--- ---
### Epson — Fan-Fold / Label Printer ### Epson ColorWorks C3500 — Fan-Fold Label Printer
**Status:** Not yet tested. Section to be filled in after testing. **Card stock:** 4" × 6" fan-fold paper label stock
**Layout code:** `badge_4x6_fanfold`
**Status:** Configured. First live use: Axonius Adapt DC — June 9, 2026.
Common Epson models used for fan-fold name badge stock: TM-T88 series, C3500, LX series. The C3500 is a color inkjet label printer — it prints continuous fan-fold paper stock,
Fan-fold stock is typically 4" × 3" or 4" × 6" paper labels. not individual cards. Badges are separated along the perforation after printing.
#### Physical Setup
- Connect via USB or Ethernet
- Load 4" × 6" fan-fold stock per Epson instructions
- The C3500 is single-sided — only the front face prints. Back section is suppressed in CSS.
- The badge has a lanyard hole punch: 5/8" × 1/8", centered, 1/4" from the top.
Most fan-fold stock for badge use includes a pre-punched lanyard slot — verify stock matches.
#### Driver
- Epson ColorWorks C3500 CUPS driver available from epson.com (ColorWorks section)
- On Linux/CUPS: install the provided PPD and add the printer at `http://localhost:631`
- Set default paper size to **4" × 6"** in CUPS
- Print a test page from CUPS before going live
#### Chrome Print Settings (C3500)
| Setting | Value |
|---|---|
| Destination | Epson C3500 (CUPS name) |
| Paper size | 4 × 6 in (set in CUPS driver) |
| Margins | **None** |
| Background graphics | On |
| Pages | 1 (single-sided) |
#### CSS Layout #### CSS Layout
Fan-fold badges would use a layout sized to the specific label stock. The C3500 uses the `badge_4x6_fanfold` layout. CSS file:
A new CSS layout file will need to be created per stock size if not already present. `src/lib/ae_events/badges/css/badge_layout_epson_4x6_fanfold.css`
Naming convention: `badge_layout_epson_[model]_[size].css`
#### Setup Notes Created 2026-05-15 for Axonius Adapt DC. Key specs:
- `badge_front` 4" × 6", portrait orientation
*(To be filled in after testing — cover: driver source, CUPS setup, paper size, Chrome settings)* - `badge_header` max-height 1.5in
- Lanyard hole: 5/8" × 1/8", centered, 1/4" from top
- `@page { size: 4in 6in; margin: 0; }` set in the print page dynamically
- `.badge_back` suppressed in `@media print` (single-sided)
#### Known Behaviors #### Known Behaviors
*(To be filled in after testing)* - Same Chrome margin rules apply: **Margins → None** prevents URL/date header clipping
- Firefox honors `@page { size: 4in 6in }` for PDF proofing — use it to verify layout
- Fan-fold stock separates along the perforation — no cutting needed, but verify the
perforation lands outside the badge content area
--- ---

View File

@@ -18,6 +18,14 @@ server needed.
- **Stock:** 3.5" × 5.5" PVC cards. - **Stock:** 3.5" × 5.5" PVC cards.
- **Orientation:** Cards face-up, landscape in the hopper. - **Orientation:** Cards face-up, landscape in the hopper.
- **Single-Sided:** Only the front face prints; the back section is hidden via CSS. - **Single-Sided:** Only the front face prints; the back section is hidden via CSS.
- **Layout code:** `badge_3.5x5.5_pvc`
### Printer Reference: Epson ColorWorks C3500 (Fan-Fold)
- **Stock:** 4" × 6" fan-fold paper label stock.
- **Single-Sided:** Only the front face prints; the back section is hidden via CSS.
- **Layout code:** `badge_4x6_fanfold`
- **Lanyard hole:** Pre-punched 5/8" × 1/8" slot at top center — verify stock matches.
- **First live use:** Axonius Adapt DC, June 9, 2026.
### Printing Workflow ### Printing Workflow
1. **Search:** Find the attendee by name or QR scan in the Badges module. 1. **Search:** Find the attendee by name or QR scan in the Badges module.

View File

@@ -114,17 +114,19 @@ corresponding `ticket_N_text` on the template provides the HTML rendered on the
| `priority`, `sort`, `group` | int/str | Standard AE sort fields | | `priority`, `sort`, `group` | int/str | Standard AE sort fields |
| `notes` | str | Internal notes | | `notes` | str | Internal notes |
### New Field (pending backend addition) ### Duplex / Single-Sided
| Field | Type | Notes | | Field | Type | Notes |
|---|---|---| |---|---|---|
| `duplex` | bool | **Planned** — when `false`, back section is hidden from print (`@media print`) | | `duplex` | bool | When `false`, back section is hidden from print (`@media print`) |
The `duplex` field controls whether the back-of-badge section renders during printing. The `duplex` field controls whether the back-of-badge section renders during printing.
When `false` (single-sided), `badge_back` gets `print:hidden` applied so only the front When `false` (single-sided), `badge_back` gets `print:hidden` applied so only the front
prints. The back section still displays on screen for configuration reference. prints. The back section still displays on screen for configuration reference.
The first event using this system (Axonius, NYC, mid-April 2026) uses single-sided PVC `duplex` is in `properties_to_save` and `show_badge_back` is derived from it in
cards on a Zebra ZC10L — `duplex` will be `false` for that event's templates. `ae_comp__badge_obj_view.svelte`. (Verified 2026-03-18)
Axonius events use `duplex = false` — single-sided printing only.
--- ---
@@ -155,7 +157,7 @@ The print page (`print/+page.svelte`) or the badge view should conditionally add
</svelte:head> </svelte:head>
``` ```
This is not yet implemented — tracked as a pending Phase 1 item. This is implemented — `style_href` loads via `<svelte:head>` in `print/+page.svelte` and is included in `properties_to_save`. (Verified 2026-03-18)
### CSS Scope ### CSS Scope
@@ -218,7 +220,6 @@ The `properties_to_save` array in `ae_events__event_badge_template.ts` controls
gets cached locally. Current state — fields **NOT** in properties_to_save that exist gets cached locally. Current state — fields **NOT** in properties_to_save that exist
in DB and may be needed: in DB and may be needed:
- `style_href` — needed once external CSS is wired via `<svelte:head>`
- `passcode` — not needed client-side - `passcode` — not needed client-side
- `footer_title`, `footer_left`, `footer_right` — not needed (legacy) - `footer_title`, `footer_left`, `footer_right` — not needed (legacy)
- `header_background`, `footer_background` — not needed (legacy) - `header_background`, `footer_background` — not needed (legacy)

View File

@@ -15,8 +15,8 @@
## 🔴 Axonius DC — June 9 (Badge Printing) ## 🔴 Axonius DC — June 9 (Badge Printing)
**Setup/Registration:** June 8 | **Show:** June 9 **Setup/Registration:** June 8 | **Show:** June 9
- [ ] **[Badges] Epson C3500 fanfold badge layout** — Create/configure a fanfold badge layout - [x] **[Badges] Epson C3500 fanfold badge layout** — `badge_4x6_fanfold` layout CSS created,
compatible with the Epson C3500 continuous stock format. wired, and documented. First live use: Axonius Adapt DC, June 9, 2026. (2026-05-15)
--- ---

View File

@@ -42,6 +42,7 @@ let copy_status: Record<string, 'idle' | 'copied'> = $state({});
// Access level shortcuts // Access level shortcuts
let is_trusted = $derived($ae_loc.trusted_access === true); let is_trusted = $derived($ae_loc.trusted_access === true);
let is_admin = $derived($ae_loc.administrator_access === true); let is_admin = $derived($ae_loc.administrator_access === true);
let is_manager = $derived($ae_loc.manager_access === true);
let is_public = $derived($ae_loc.public_access === true); // public passcode or higher — may print first prints let is_public = $derived($ae_loc.public_access === true); // public passcode or higher — may print first prints
let is_edit_mode = $derived($ae_loc.edit_mode === true); let is_edit_mode = $derived($ae_loc.edit_mode === true);
@@ -113,12 +114,24 @@ let visible_badge_obj_li = $derived(
if (ps === 'not_printed') return (item.print_count ?? 0) < 1 && hide_ok; if (ps === 'not_printed') return (item.print_count ?? 0) < 1 && hide_ok;
return hide_ok; // 'all' return hide_ok; // 'all'
} }
// Public (kiosk) / authenticated / anonymous: only unprinted, never hidden. // Public/Attendee (public_access, not trusted): show all non-hidden.
// Badge kiosks run at public_access — attendees should only see their own // Printed badges appear read-only so attendees can see their check-in status.
// unprinted badge, never a list of already-printed ones. if (is_public) return !item.hide;
// Below public (anonymous, authenticated): only unprinted, never hidden.
return (item.print_count ?? 0) < 1 && !item.hide; return (item.print_count ?? 0) < 1 && !item.hide;
}); });
// For public (attendee) kiosk: sort unprinted first so attendees find their
// own badge at the top; already-checked-in records fall to the bottom.
if (is_public && !is_trusted && filtered.length > 1) {
filtered.sort((a: any, b: any) => {
const a_p = (a.print_count ?? 0) > 0;
const b_p = (b.print_count ?? 0) > 0;
if (a_p !== b_p) return a_p ? 1 : -1;
return 0; // preserve existing name order within each group
});
}
if (log_lvl) if (log_lvl)
console.log( console.log(
`visible_badge_obj_li: Input=${list.length}, Output=${filtered.length}` `visible_badge_obj_li: Input=${list.length}, Output=${filtered.length}`
@@ -154,9 +167,13 @@ let visible_badge_obj_li = $derived(
event_badge_obj?.full_name_override ?? event_badge_obj?.full_name_override ??
event_badge_obj?.full_name ?? event_badge_obj?.full_name ??
`${event_badge_obj?.given_name ?? ''} ${event_badge_obj?.family_name ?? ''}`.trim()} `${event_badge_obj?.given_name ?? ''} ${event_badge_obj?.family_name ?? ''}`.trim()}
{@const can_print = (is_public && !is_printed) || (is_trusted && is_edit_mode)} {@const is_attendee_view = is_public && !is_trusted}
{@const is_attendee_printed = is_attendee_view && is_printed}
{@const can_print = is_trusted && (!is_printed || is_edit_mode)}
{@const row_clickable = can_print || (is_attendee_view && !is_printed)}
{@const print_href = `/events/${event_badge_obj?.event_id}/badges/${event_badge_obj?.event_badge_id}/print`} {@const print_href = `/events/${event_badge_obj?.event_id}/badges/${event_badge_obj?.event_badge_id}/print`}
{@const review_href = build_review_url(event_badge_obj)} {@const review_href = build_review_url(event_badge_obj)}
{@const row_href = is_trusted ? print_href : review_href}
<li <li
class=" class="
@@ -169,18 +186,13 @@ let visible_badge_obj_li = $derived(
class="flex w-full flex-row flex-wrap items-center justify-between gap-2"> class="flex w-full flex-row flex-wrap items-center justify-between gap-2">
<!-- Left cluster: main action (name + email + affiliations) --> <!-- Left cluster: main action (name + email + affiliations) -->
<div class="flex min-w-0 grow flex-row flex-wrap items-center gap-x-3 gap-y-1"> <div class="flex min-w-0 grow flex-row flex-wrap items-center gap-x-3 gap-y-1">
{#if can_print} {#if row_clickable}
<a <a
href={print_href} href={row_href}
class="hover:text-primary-800-200 hover:bg-primary-200-800 active:bg-surface-200-700 flex items-center gap-3 justify-start px-3 py-2 text-left text-lg font-bold flex-1 transition-colors duration-1000 hover:duration-300 min-w-0 preset-tonal-primary rounded-lg" class="hover:text-primary-800-200 hover:bg-primary-200-800 active:bg-surface-200-700 flex items-center gap-3 justify-start px-3 py-2 text-left text-lg font-bold flex-1 transition-colors duration-1000 hover:duration-300 min-w-0 preset-tonal-primary rounded-lg"
title=" title={is_trusted
Print badge for ? `Print badge for ${display_name} · ${event_badge_obj?.affiliations_override ?? event_badge_obj?.affiliations ?? ''} · ${event_badge_obj?.badge_type ?? ''} · id: ${event_badge_obj.event_badge_id}`
{display_name} : `Review your badge info`}
email: <{is_trusted ? event_badge_obj?.email : obscure_email(event_badge_obj?.email)}>
{event_badge_obj?.affiliations_override ?? event_badge_obj?.affiliations}
type: {event_badge_obj?.badge_type}
id: {event_badge_obj.event_badge_id}
"
> >
{#if event_badge_obj?.hide} {#if event_badge_obj?.hide}
<EyeOff size="1.1em" class="text-gray-400" /> <EyeOff size="1.1em" class="text-gray-400" />
@@ -208,6 +220,23 @@ let visible_badge_obj_li = $derived(
{/if} {/if}
</div> </div>
</a> </a>
{:else if is_attendee_printed}
<!-- Public attendee, badge already printed: show check-in status, no action -->
<div class="flex items-center gap-3 justify-start px-3 py-2 flex-1 min-w-0 rounded-lg bg-success-500/10">
<Check size="1.1em" class="text-success-600 dark:text-success-400 shrink-0" />
<div class="flex flex-col min-w-0">
<span class="truncate font-semibold">{display_name}</span>
<span class="text-xs text-success-700 dark:text-success-300 opacity-80">
Checked in &middot; {print_count}&times;
{#if event_badge_obj.print_first_datetime}
&middot; First: {ae_util.iso_datetime_formatter(event_badge_obj.print_first_datetime, 'datetime_iso_12_no_seconds')}
{/if}
{#if event_badge_obj.print_last_datetime}
&middot; Last: {ae_util.iso_datetime_formatter(event_badge_obj.print_last_datetime, 'datetime_iso_12_no_seconds')}
{/if}
</span>
</div>
</div>
{:else} {:else}
<div <div
class="hover:text-primary-800-200 hover:bg-primary-200-800 active:bg-surface-200-700 flex items-center gap-3 justify-start px-3 py-2 text-left text-lg font-bold flex-1 transition-colors duration-1000 hover:duration-300 min-w-0 preset-tonal-primary rounded-lg cursor-not-allowed" class="hover:text-primary-800-200 hover:bg-primary-200-800 active:bg-surface-200-700 flex items-center gap-3 justify-start px-3 py-2 text-left text-lg font-bold flex-1 transition-colors duration-1000 hover:duration-300 min-w-0 preset-tonal-primary rounded-lg cursor-not-allowed"
@@ -256,8 +285,8 @@ let visible_badge_obj_li = $derived(
<!-- Right: up to 4 action buttons --> <!-- Right: up to 4 action buttons -->
<div class="flex shrink-0 flex-row items-center gap-1"> <div class="flex shrink-0 flex-row items-center gap-1">
<!-- 1. Print Badge: Public+ first print; Trusted + Edit Mode for reprint --> <!-- 1. Print Badge: Trusted — first print anytime, reprint in Edit Mode or Manager+ -->
{#if (is_public && !is_printed) || (is_trusted && is_edit_mode)} {#if is_trusted && (!is_printed || is_edit_mode || is_manager)}
<a <a
href={`/events/${event_badge_obj?.event_id}/badges/${event_badge_obj?.event_badge_id}/print`} href={`/events/${event_badge_obj?.event_id}/badges/${event_badge_obj?.event_badge_id}/print`}
class="hover:text-primary-800-200 hover:bg-primary-200-800 active:bg-surface-200-700 flex items-center gap-1 px-3 py-2 text-base font-bold transition-colors duration-1000 hover:duration-300 min-w-0 preset-tonal-primary rounded-lg border border-primary-200-800 class="hover:text-primary-800-200 hover:bg-primary-200-800 active:bg-surface-200-700 flex items-center gap-1 px-3 py-2 text-base font-bold transition-colors duration-1000 hover:duration-300 min-w-0 preset-tonal-primary rounded-lg border border-primary-200-800
@@ -312,9 +341,8 @@ let visible_badge_obj_li = $derived(
</button> </button>
{/if} {/if}
<!-- 4. Email Review Link: Administrator + Edit Mode only <!-- 4. Email Review Link: Manager+ always; Administrator + Edit Mode otherwise -->
Temporarily restricted — TODO: restore broader access after Axonius 2026 --> {#if is_admin && (is_edit_mode || is_manager)}
{#if is_admin && is_edit_mode}
<button <button
type="button" type="button"
class="hover:text-primary-800-200 hover:bg-primary-200-800 active:bg-surface-200-700 flex items-center gap-1 px-3 py-2 text-base font-bold transition-colors duration-1000 hover:duration-300 min-w-0 preset-tonal-primary rounded-lg border border-primary-200-800" class="hover:text-primary-800-200 hover:bg-primary-200-800 active:bg-surface-200-700 flex items-center gap-1 px-3 py-2 text-base font-bold transition-colors duration-1000 hover:duration-300 min-w-0 preset-tonal-primary rounded-lg border border-primary-200-800"
@@ -382,10 +410,11 @@ let visible_badge_obj_li = $derived(
<div <div
class="flex flex-col items-center justify-center p-20 text-center opacity-50"> class="flex flex-col items-center justify-center p-20 text-center opacity-50">
<FileSearch size="3em" class="mx-auto mb-2 opacity-20" /> <FileSearch size="3em" class="mx-auto mb-2 opacity-20" />
<p> {#if !is_trusted && !(badges_loc.current.fulltext_search_qry_str ?? '').trim()}
No badges found matching your criteria. Try adjusting your <p>Enter your name above to find your badge.</p>
filters. {:else}
</p> <p>No badges found matching your criteria. Try adjusting your filters.</p>
{/if}
</div> </div>
{/if} {/if}
</section> </section>