Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ end
- `BuiltinCalloutCards` — Renders the live, per-registration ticket callout cards (payment, certificate, scholarship, CE hours, videoconference), overlaying dynamic status (badge, colour, visibility guard, destination) on each materialized built-in row via `#card_for`. Rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `BuiltinCallouts`) so the two paths never double-render, and `#cards` serves as the fallback for events not yet seeded; `.editor_cards` builds the editor's preview cards. Art supplies, Handouts, and FAQ are pure content cards with no builder here — they render from their row. Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized)
- `BuiltinCallouts` — Owns the built-in callout definitions and materializes them into `RegistrationTicketCallout` rows in canonical ticket order: `seed` persists (on create, and lazily on edit so older events heal with no backfill), `build` makes the same rows in memory for the new-event form (with `builtin_key` round-tripped through nested attributes), `reset`/`customized?` back the "Restore default" control. All eight seed **hidden** by default — admins publish the ones they want; there's no config-based auto-publish. Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Art supplies, Handouts, FAQ) render their own copy/resources on the generic callout page; "behavioral" cards render live status through `BuiltinCalloutCards#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Videoconference drips a week before start via `display_from`. CE hours and Art supplies are edited like every other built-in — their title/text live entirely on the row (the legacy `event_details*`/`ce_hours_details*` event columns were dropped); the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE page reads the row's title/description. Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`)
- `CalloutContent` — Parses admin-authored callout HTML into ordered segments so **every** callout content page renders the same way: plain rich text, with each standard `<details><summary>…</summary>…</details>` disclosure (the markup any HTML generator/LLM produces; `<toggle>` and a `title` attribute are accepted aliases; `<details open>` starts expanded) rebuilt into a styled collapsible card. `<details>`/`<summary>` are also on the `form_label_html` allowlist (`FORM_LABEL_TAGS`, plus the `open` attribute), so a disclosure is never stripped on save — the parser only upgrades its styling. Rendered through the shared `app/views/events/callouts/_rich_content.html.erb` partial (which wraps each disclosure in `_toggle.html.erb`), used by the art-supplies ("Art supplies & what to bring", a content callout on the generic page), CE hours, custom-callout, behavioural-card-intro, and FAQ pages. The FAQ page renders the editable `faq` callout `description` (each question a `<details>`), falling back to `BuiltinCallouts.faq_html` when the card isn't materialized. Content with no disclosure renders unchanged
- `SampleTicketRegistration` — Builds the **unsaved, data-free** `EventRegistration` ("Sample Person") that the sample ticket and its admin-only callout-page previews render from; nothing is ever persisted, so the preview can't read from or write to a real registrant or leak into counts/revenue/rosters/reminders. `all_options:` mirrors the ticket's "Show all options" toggle (turns on scholarship/CE/W-9 so those cards and preview pages render). Shared by `EventsController#sample_ticket` and `Events::CalloutsController`'s sample mode (the `sample` param → admin-authed in-memory previews of the behavioral built-in pages, linked from the sample ticket via `EventHelper#sample_callout_path`)

### Affiliations

Expand Down
38 changes: 32 additions & 6 deletions app/controllers/events/callouts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ module Events
# Each is reachable by the registration slug — the slug is the authorization,
# so no login is required (mirrors the public ticket/invoice pages).
class CalloutsController < ApplicationController
skip_before_action :authenticate_user!
# Real registrant pages are public (the slug is the authorization); the
# sample-ticket previews instead require an admin (authorized in #authorize_callout).
skip_before_action :authenticate_user!, unless: :sample_preview?
before_action :set_event_registration
before_action :authorize_callout
before_action :set_event
# These pages carry an editable intro (the built-in row's "Callout page text")
# above the app-controlled content, plus any resources linked to the row.
before_action :set_builtin_content, only: %i[ payment scholarship certificate videoconference ]

helper_method :sample_preview?

# The single-resource page previews a PDF in an <object> (object-src). The
# global policy blocks <object>/<embed> with object_src :none; relax to :self
# only here, matching ResourcesController#show. The blob streams same-origin
Expand All @@ -34,13 +38,19 @@ class CalloutsController < ApplicationController
# the linked documents (the W-9 from the payment callout's resources, and the
# dynamic invoice/receipt) for paid events.
def payment
@allocations = @event_registration.allocations.includes(:source).order(:created_at)
@document_cards = payment_document_cards
# The sample preview has no ledger or documents to link (its sentinel slug
# wouldn't resolve), so it just shows the empty structure.
@allocations = sample_preview? ? [] : @event_registration.allocations.includes(:source).order(:created_at)
@document_cards = sample_preview? ? [] : payment_document_cards
end

# Certificate of completion, rendered like the invoice. Only reachable once
# the certificate is unlocked.
def certificate
# The sample preview always shows the template; a real registrant only sees
# it once the certificate is unlocked.
return if sample_preview?

unless @event_registration.certificate_available?
redirect_to registration_ticket_path(@event_registration.slug)
end
Expand Down Expand Up @@ -188,16 +198,32 @@ def builtin_published?(builtin_key)
@event.registration_ticket_callouts.exists?(builtin_key: builtin_key, hidden: false)
end

# Admin-only preview from the sample ticket. Renders these pages for an
# unsaved, data-free sample registration instead of a real one looked up by
# slug, so nothing is ever read from or written to a real registrant.
def sample_preview?
params[:sample].present?
end

def set_event_registration
@event_registration = EventRegistration.find_by!(slug: params[:slug])
if sample_preview?
@event = Event.find(params[:id])
@event_registration = SampleTicketRegistration.new(@event, all_options: true).registration
else
@event_registration = EventRegistration.find_by!(slug: params[:slug])
end
end

def authorize_callout
authorize! @event_registration, to: :show_public?
if sample_preview?
authorize! @event, to: :dashboard?
else
authorize! @event_registration, to: :show_public?
end
end

def set_event
@event = @event_registration.event
@event ||= @event_registration.event
end

# The editable intro and linked resources for a built-in page, from the
Expand Down
36 changes: 11 additions & 25 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,20 +67,18 @@ def dashboard
def sample_ticket
authorize! @event, to: :dashboard?

# Materialize any missing built-in callouts (idempotent, like #edit) so the
# preview renders purely from the event's materialized rows — the built-ins
# are real editable rows once seeded, so there's no code-defined fallback to
# fall back to here.
BuiltinCallouts.seed(@event)

@show_all_options = params[:options] == "all"
registrant = Person.new(first_name: "Sample", last_name: "Registrant")
@event_registration = @event.event_registrations.new(
registrant: registrant,
slug: "sample",
status: "registered",
intends_to_pay: true,
w9_requested: @show_all_options,
invoice_requested: @show_all_options,
scholarship_requested: @show_all_options,
shoutout: @show_all_options,
created_at: Time.current
)
build_sample_ce_registration if @show_all_options
# Always an unsaved, data-free sample so the preview can never read from or
# write to a real registrant. Its behavioral built-in cards link to the
# admin-only sample callout previews (see Events::CalloutsController), which
# render the same in-memory sample — nothing is ever persisted.
@event_registration = SampleTicketRegistration.new(@event, all_options: @show_all_options).registration
end

def background
Expand Down Expand Up @@ -507,18 +505,6 @@ def copy_registration_form

private

# Build (unsaved) a CE registration on the sample ticket so the "Show all
# options" preview renders a populated CE card. Mirrors a complete, paid-looking
# CE record without touching the database.
def build_sample_ce_registration
license = ProfessionalLicense.new(person: @event_registration.registrant, number: "SAMPLE-12345")
@event_registration.continuing_education_registrations.build(
professional_license: license,
hours: @event.ce_hours_offered || 6,
cost_cents: @event.ce_hours_cost_cents || 15_000
)
end

# The registrations the admin checked on the recipient picker, narrowed to those
# we can actually email. Shared by the confirm interstitial and the send action
# so both operate on exactly the same set.
Expand Down
13 changes: 13 additions & 0 deletions app/helpers/event_helper.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
module EventHelper
# The admin-only sample-ticket preview path for a behavioral built-in callout's
# per-registration page, keyed off its builtin_key. Returns nil for a callout
# with no previewable page, so the sample ticket leaves that card non-navigating.
def sample_callout_path(event, callout)
case callout.builtin_key
when "payment" then sample_payment_event_path(event)
when "certificate" then sample_certificate_event_path(event)
when "scholarship" then sample_scholarship_event_path(event)
when "ce_hours" then sample_ce_event_path(event)
when "videoconference" then sample_videoconference_event_path(event)
end
end

# The "please specify" placeholder for an option label, or nil when the option
# does not reveal a free-text box. Canonical config lives on FormField (shared
# with answer validation).
Expand Down
22 changes: 14 additions & 8 deletions app/services/builtin_callout_cards.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,12 @@ def self.config_gap(event, builtin_key)
end
end

# `preview: true` is the sample ticket: it bypasses the config gaps so an admin
# can preview (and click through) a published built-in card even before the
# event carries the config it depends on (a cost, a scholarship form, CE hours).
def initialize(event_registration, preview: false)
@registration = event_registration
@event = event_registration.event
# The admin sample-ticket preview illustrates a hypothetical registrant with
# options toggled on, so the scholarship / CE cards render from the sample
# registration's options even when the concrete event isn't configured for them
# (their config_gap is skipped). On a real ticket this stays false.
@preview = preview
end

Expand Down Expand Up @@ -135,6 +134,13 @@ def card_for(callout)

attr_reader :registration, :event

# Whether this event lacks the config the built-in card depends on, so it can't
# reach a real ticket. The sample-ticket preview ignores the gap so an admin can
# still see and click through the card while finishing the event's setup.
def config_gap?(builtin_key)
!@preview && self.class.config_gap(event, builtin_key).present?
end

# A built-in card the event has materialized into an editable row renders from
# that row, not from #cards, so we skip it here to avoid double-rendering.
def materialized?(builtin_key)
Expand All @@ -146,7 +152,7 @@ def materialized?(builtin_key)
# in full. Its page lists every allocation with the running balance, plus the
# linked documents (the W-9, and the invoice/receipt) for paid events.
def payment_card
return if self.class.config_gap(event, "payment")
return if config_gap?("payment")
due = registration.remaining_cost.to_i.positive?
Card.new(icon_class: "fa-solid fa-credit-card", color: due ? "orange" : "blue",
title: due ? "Make your payment" : "Payment",
Expand All @@ -173,7 +179,7 @@ def certificate_card
# pending shows an amber "$X · Tasks outstanding" badge (action needed); fully
# met shows a fuchsia amount badge.
def scholarship_status_card
return if !@preview && self.class.config_gap(event, "scholarship")
return if config_gap?("scholarship")
return unless registration.scholarship_requested?
# Awarded is display-only: the scholarship record exists earlier, but the award
# is only shown as awarded once the recipient signs the agreement. Until then
Expand Down Expand Up @@ -210,7 +216,7 @@ def scholarship_badge(awarded, tasks_outstanding)
# they have, becoming a reference card once requested with hours and a license
# number on file. Shown when the event offers CE or the registrant asked for it.
def ce_hours_card
return if !@preview && self.class.config_gap(event, "ce_hours")
return if config_gap?("ce_hours")
return unless registration.ce_registered?
complete = registration.ce_license_provided?
# An outstanding CE balance turns the card orange (an action card), matching
Expand Down Expand Up @@ -272,7 +278,7 @@ def ce_deadline_text(deadline)

# Shown only when the event has a videoconference URL set.
def videoconference_card
return if self.class.config_gap(event, "videoconference")
return if config_gap?("videoconference")
Card.new(icon_class: "fa-solid fa-video", color: "blue",
title: "Videoconference",
subtitle: "Join link and how to add it to your calendar",
Expand Down
42 changes: 42 additions & 0 deletions app/services/sample_ticket_registration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Builds the unsaved, data-free registration the sample ticket (and its
# admin-only callout page previews) render from. Nothing is ever persisted, so
# the preview can never read from or write to a real registrant, nor leak into
# counts, revenue, rosters, or reminders. Shared by EventsController#sample_ticket
# and Events::CalloutsController's sample mode so both preview the same person.
class SampleTicketRegistration
# `all_options` mirrors the ticket's "Show all options" toggle: it turns on the
# scholarship/CE/W-9 flags so those callout cards (and their preview pages)
# render as they would for a registrant using every option.
def initialize(event, all_options: false)
@event = event
@all_options = all_options
end

def registration
registrant = Person.new(first_name: "Sample", last_name: "Person")
registration = @event.event_registrations.new(
registrant: registrant,
slug: "sample",
status: "registered",
intends_to_pay: true,
w9_requested: @all_options,
invoice_requested: @all_options,
scholarship_requested: @all_options,
shoutout: @all_options,
created_at: Time.current
)
build_ce_registration(registration) if @all_options
registration
end

private

def build_ce_registration(registration)
license = ProfessionalLicense.new(person: registration.registrant, number: "SAMPLE-12345")
registration.continuing_education_registrations.build(
professional_license: license,
hours: @event.ce_hours_offered || 6,
cost_cents: @event.ce_hours_cost_cents || 15_000
)
end
end
Loading