diff --git a/AGENTS.md b/AGENTS.md
index 8cf263471..cb42e92f1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -97,7 +97,7 @@ This codebase (Rails 8.1)
| `Event` | Events with registrations, featured/published states |
| `EventStaff` | Join model connecting `Person` to `Event` as staff (title, `expected_to_attend`); drives the "Meet the staff" roster and "My events" |
| `EventRegistrationChecklistCompletion` | Audited completion row for one manual onboarding step on an `EventRegistration` (`step` from `EventRegistration::CHECKLIST_STEPS`, `completed_by` User, `completed_at`); row-exists = done. Powers the event Onboarding tab's checkbox matrix |
-| `RegistrationTicketCallout` | Call-outs shown on an event's registration ticket (title, subtitle, HTML description, `callout_type` action/reference, icon/colour, `payment_access_gated` — only shown once the registrant has `payment_access_granted?`, draggable `position`, `hidden` draft/opt-out, `display_from` drip date, and `has_many :resources` through `RegistrationTicketCalloutResource`); each links to its own public detail page. A nil `magic_key` is an admin-authored callout; a set `magic_key` is a built-in card materialized by `DefaultTicketCallouts` (hidden instead of deleted, restorable to default) |
+| `RegistrationTicketCallout` | Call-outs shown on an event's registration ticket (title, subtitle, HTML description, `callout_type` action/reference, icon/colour, `payment_access_gated` — only shown once the registrant has `payment_access_granted?`, draggable `position`, `hidden` draft/opt-out, `display_from` drip date, and `has_many :resources` through `RegistrationTicketCalloutResource`); each links to its own public detail page. A nil `builtin_key` is an admin-authored callout; a set `builtin_key` is a built-in card materialized by `BuiltinCallouts` (hidden instead of deleted, restorable to default) |
| `RegistrationTicketCalloutResource` | Ordered join linking a `RegistrationTicketCallout` to the `Resource`s shown on its detail page |
| `Story` | Editorial content with facilitators, primary/gallery assets |
| `Resource` | Handouts, toolkits, templates with downloadable assets |
@@ -207,9 +207,9 @@ end
- `EventRegistrationServices::PublicRegistration` — Public registration handling
- `EventRegistrationReadiness` — Computes a registration's lifecycle `status` (`:not_ready` → `:ready` → `:certificate_due` → `:completed`) from a pre-event "event ready" checklist, a post-event "completion work" checklist (attendance, scholarship tasks), and certificate delivery, returning the specific outstanding reasons. Reads payment/certificate state via `Registerable` (`paid_in_full?`, `certificate_sent?`) on both the registration and its `continuing_education_registrations`. Drives the registrants roster's single far-right Status badge column (with a short reason under "Not ready" and a cert-type note under "Certificate pending") and its matching filter
- `ReminderRecipientFilter` — Decides which event registrations stay checked on the bulk reminder page given the admin's filters (matches in memory, returns matching ids)
-- `MagicTicketCallouts` — Code-defined ("magic") ticket callout cards (payment, certificate, scholarship, CE hours, art supplies, handouts, portal, videoconference, FAQ), each with its own visibility rule; rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `DefaultTicketCallouts`) so the two paths never double-render, and serves as the fallback for events not yet seeded. Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized)
-- `DefaultTicketCallouts` — Materializes all built-in callouts into `RegistrationTicketCallout` rows in canonical ticket order, seeded on event create and lazily on edit, each gated by the event's config via `seed_if` (idempotent, no backfill). 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 (Handouts, FAQ) render their own copy/resources; "behavioral" cards render live status through `MagicTicketCallouts#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. Certificate is opt-in (default off except trainings); Videoconference drips a week before start via `display_from`. CE hours and Event details are edited like every other built-in — their title/text seed from the event's columns once (migrating existing content) then live on the row; the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE/details pages read the row (falling back to the event columns pre-seed). 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 `……` disclosure (the markup any HTML generator/LLM produces; `` and a `title` attribute are accepted aliases; `` starts expanded) rebuilt into a styled collapsible card. ``/`` 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 event-details ("Art supplies & what to bring"), CE hours, custom-callout, behavioural-card-intro, and FAQ pages. The FAQ page renders the editable `faq` callout `description` (each question a ``), falling back to `DefaultTicketCallouts.faq_html` when the card isn't materialized. Content with no disclosure renders unchanged
+- `BuiltinCalloutCards` — Renders the live, per-registration ticket callout cards (payment, certificate, scholarship, CE hours, event details, 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. Handouts/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 (Handouts, FAQ) render their own copy/resources; "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 Event details are edited like every other built-in — their title/text seed from the event's columns once (migrating existing content) then live on the row; the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE/details pages read the row (falling back to the event columns pre-seed). 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 `……` disclosure (the markup any HTML generator/LLM produces; `` and a `title` attribute are accepted aliases; `` starts expanded) rebuilt into a styled collapsible card. ``/`` 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 event-details ("Art supplies & what to bring"), CE hours, custom-callout, behavioural-card-intro, and FAQ pages. The FAQ page renders the editable `faq` callout `description` (each question a ``), falling back to `BuiltinCallouts.faq_html` when the card isn't materialized. Content with no disclosure renders unchanged
### Affiliations
diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb
index be4ff8caf..51fcd776b 100644
--- a/app/controllers/events/callouts_controller.rb
+++ b/app/controllers/events/callouts_controller.rb
@@ -1,5 +1,5 @@
module Events
- # Public show pages for a registration ticket's magic callouts (payment, CE,
+ # Public show pages for a registration ticket's built-in callouts (payment, CE,
# scholarship, handouts, videoconference, FAQ, certificate).
# Each is reachable by the registration slug — the slug is the authorization,
# so no login is required (mirrors the public ticket/invoice pages).
@@ -145,8 +145,8 @@ def request_ce
# (PDF preview + download, with a back-to-handouts eyebrow). Cards read their
# subtitle from the materialized handouts callout's join rows.
def handouts
- return redirect_to registration_ticket_path(@event_registration.slug) unless @event.show_handouts_callout?
- callout = @event.registration_ticket_callouts.find_by(magic_key: "handouts")
+ return redirect_to registration_ticket_path(@event_registration.slug) unless builtin_published?("handouts")
+ callout = @event.registration_ticket_callouts.find_by(builtin_key: "handouts")
@handout_cards = resource_cards_for(callout, icon: "fa-solid fa-file-pdf", return_to: "handouts")
end
@@ -171,13 +171,21 @@ def videoconference
# question), falling back to the code-defined default for events that haven't
# materialized the card yet.
def faq
- return redirect_to(registration_ticket_path(@event_registration.slug)) unless @event.show_faq_callout?
- callout = @event.registration_ticket_callouts.find_by(magic_key: "faq")
- @faq_content = callout&.description.presence || DefaultTicketCallouts.faq_html
+ return redirect_to(registration_ticket_path(@event_registration.slug)) unless builtin_published?("faq")
+ callout = @event.registration_ticket_callouts.find_by(builtin_key: "faq")
+ @faq_content = callout&.description.presence || BuiltinCallouts.faq_html
end
private
+ # Whether the event's built-in callout for this key is materialized and
+ # published (visible). These public pages gate on that alone now — the admin's
+ # published/hidden choice on the row decides whether the page is reachable, so
+ # a hidden or not-yet-materialized card can't surface via a stray link.
+ def builtin_published?(builtin_key)
+ @event.registration_ticket_callouts.exists?(builtin_key: builtin_key, hidden: false)
+ end
+
def set_event_registration
@event_registration = EventRegistration.find_by!(slug: params[:slug])
end
@@ -191,14 +199,14 @@ def set_event
end
# The editable intro and linked resources for a built-in page, from the
- # materialized callout row for this action's magic_key. Nil/empty when the
+ # materialized callout row for this action's builtin_key. Nil/empty when the
# event hasn't materialized the card. Payment renders its own document list
# (W-9 + invoice/receipt) in its Documents section, so it skips the generic
# resource list here. Resources render as callout cards linking to their own
# registrant page (PDF preview + download), each returning to this callout —
# never inline.
def set_builtin_content
- callout = @event.registration_ticket_callouts.find_by(magic_key: action_name)
+ callout = @event.registration_ticket_callouts.find_by(builtin_key: action_name)
@builtin_intro = callout&.description.presence
callout = nil if action_name == "payment"
@builtin_resource_cards = resource_cards_for(callout, icon: "fa-solid fa-file-lines", return_to: action_name)
@@ -224,8 +232,8 @@ def origin_callout
case params[:return_to]
when "callout"
@event.registration_ticket_callouts.find_by(id: params[:callout_id])
- when *RegistrationTicketCallout::MAGIC_KEYS
- @event.registration_ticket_callouts.find_by(magic_key: params[:return_to])
+ when *RegistrationTicketCallout::BUILTIN_KEYS
+ @event.registration_ticket_callouts.find_by(builtin_key: params[:return_to])
end
end
@@ -282,7 +290,7 @@ def payment_resource_card(link, slug)
# can add/remove them), else transient links for the W-9 on paid events not
# yet materialized.
def payment_document_resources
- payment_callout = @event.registration_ticket_callouts.find_by(magic_key: "payment")
+ payment_callout = @event.registration_ticket_callouts.find_by(builtin_key: "payment")
return payment_callout.registration_ticket_callout_resources.ordered.includes(:resource).to_a if payment_callout
return [] unless @event.cost_cents.to_i.positive?
Resource.where(title: "W-9").map { |resource| RegistrationTicketCalloutResource.new(resource:) }
@@ -291,7 +299,7 @@ def payment_document_resources
# A grey document card for a non-resource payment document (the dynamic
# invoice/receipt). Opens in a new tab, matching the callout-card contract.
def document_card(title:, subtitle:, icon:, href:)
- MagicTicketCallouts::Card.new(icon_class: icon, color: "gray", title:, subtitle:,
+ BuiltinCalloutCards::Card.new(icon_class: icon, color: "gray", title:, subtitle:,
href:, target: "_blank", trailing_icon: "fa-solid fa-arrow-right")
end
@@ -299,7 +307,7 @@ def document_card(title:, subtitle:, icon:, href:)
# available yet; the payment view renders it as a plain div with a lock icon,
# and the subtitle names what unlocks it.
def locked_document_card(title:, subtitle:, icon:)
- MagicTicketCallouts::Card.new(icon_class: icon, color: "gray", title:, subtitle:,
+ BuiltinCalloutCards::Card.new(icon_class: icon, color: "gray", title:, subtitle:,
href: nil, target: nil, trailing_icon: "fa-solid fa-lock")
end
diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb
index 88a23e0fb..8ce7e6109 100644
--- a/app/controllers/events_controller.rb
+++ b/app/controllers/events_controller.rb
@@ -37,7 +37,7 @@ def edit
authorize! @event
# Materialize any missing built-in callouts so the editor shows them all
# (idempotent; heals events created before a built-in existed).
- DefaultTicketCallouts.seed(@event)
+ BuiltinCallouts.seed(@event)
set_form_variables
end
@@ -191,7 +191,7 @@ def onboarding
def details
authorize! @event, to: :details?
- callout = @event.registration_ticket_callouts.find_by(magic_key: "event_details")
+ callout = @event.registration_ticket_callouts.find_by(builtin_key: "event_details")
@event_details_title = callout&.title.presence || @event.event_details_label
@event_details_body = callout&.description.presence || @event.event_details
@@ -452,7 +452,7 @@ def create
if params.dig(:library_asset, :new_assets).present?
update_asset_owner(@event)
end
- DefaultTicketCallouts.seed(@event)
+ BuiltinCallouts.seed(@event)
success = true
end
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e
@@ -483,7 +483,7 @@ def update
assign_associations(@event)
# Lazily materialize built-in callouts for events created before this
# existed — heals on first edit, no data backfill. Idempotent.
- DefaultTicketCallouts.seed(@event)
+ BuiltinCallouts.seed(@event)
success = true
else
raise ActiveRecord::Rollback
@@ -727,6 +727,11 @@ def set_form_variables
@event = @event.decorate
@event.build_primary_asset if @event.primary_asset.blank?
@event.gallery_assets.build
+ # Build any not-yet-present built-in callouts as in-memory rows so the editor
+ # shows them (editable, with builtin_key round-tripped) even before the event's
+ # first save. Idempotent, so it's a no-op once they exist (edit seeds real
+ # rows first; a failed create/update already carries the submitted rows).
+ BuiltinCallouts.build(@event)
@locations = Location.order(:city, :state)
@registration_forms = Form.standalone.where(role: "registration").order(:name)
@scholarship_forms = Form.standalone.where(role: "scholarship").order(:name)
diff --git a/app/decorators/registration_ticket_callout_resource_decorator.rb b/app/decorators/registration_ticket_callout_resource_decorator.rb
index 15c46f282..098f446df 100644
--- a/app/decorators/registration_ticket_callout_resource_decorator.rb
+++ b/app/decorators/registration_ticket_callout_resource_decorator.rb
@@ -13,7 +13,7 @@ def card_subtitle
# callout page and is dropped from the URL when nil. `color` themes the card —
# blue on the callout pages, grey in the payment page's documents list.
def to_card(registrant_slug:, return_to:, icon: "fa-solid fa-file-lines", color: "blue", callout_id: nil)
- MagicTicketCallouts::Card.new(
+ BuiltinCalloutCards::Card.new(
icon_class: icon, color:, title: resource.title, subtitle: card_subtitle,
href: card_href(registrant_slug:, return_to:, callout_id:),
target: nil, trailing_icon: "fa-solid fa-arrow-right"
diff --git a/app/models/event.rb b/app/models/event.rb
index 078d6c27a..b1b9e0147 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -48,7 +48,7 @@ class Event < ApplicationRecord
reject_if: proc { |attrs| attrs["person_id"].blank? }
accepts_nested_attributes_for :registration_ticket_callouts, allow_destroy: true,
# Reject only blank *new* callouts; existing rows (with an id) must always
- # process so a control-only magic row's hidden/position toggle persists even
+ # process so a control-only built-in row's hidden/position toggle persists even
# though it submits no title.
reject_if: proc { |attrs| attrs["id"].blank? && attrs["title"].blank? }
@@ -124,24 +124,6 @@ def one_click_for_signed_in?
signed_in_one_click_enabled? || registration_form.nil?
end
- # Whether the built-in "training" ticket callouts (Handouts, FAQ, Forms) appear
- # on this event's registration tickets. Their content is written for the 2-Day
- # AWBW Facilitator Training, so they only belong on those events — a public,
- # non-training event (often with no registration process) should not surface
- # training worksheets or "2-day training" FAQ on its ticket. Handouts and FAQ
- # are pure training curriculum; Forms (W-9, invoice, receipt) also belongs on
- # any paid event, whose registrants need their invoice and receipt.
- #
- # These are the default rules; a later change adds a per-event override so
- # admins can force a callout on or off from the event's callouts section.
- def show_handouts_callout?
- facilitator_training?
- end
-
- def show_faq_callout?
- facilitator_training?
- end
-
def scholarship_form
forms.find_by(event_forms: { role: "scholarship" })
end
@@ -182,7 +164,7 @@ def videoconference_window_open?
def videoconference_details_available_from
return @videoconference_details_available_from if defined?(@videoconference_details_available_from)
@videoconference_details_available_from =
- if (callout = registration_ticket_callouts.magic.find_by(magic_key: "videoconference"))
+ if (callout = registration_ticket_callouts.builtin.find_by(builtin_key: "videoconference"))
callout.display_from
elsif start_date
start_date - VIDEOCONFERENCE_DETAILS_LEAD
diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb
index 74e8546b6..017816496 100644
--- a/app/models/registration_ticket_callout.rb
+++ b/app/models/registration_ticket_callout.rb
@@ -5,31 +5,31 @@ class RegistrationTicketCallout < ApplicationRecord
# group them on the ticket later.
CALLOUT_TYPES = %w[ action reference ].freeze
- # Hidden identifiers for the built-in ("magic") callouts. A row carrying one of
- # these was seeded from a code-defined default (see DefaultTicketCallouts) and
+ # Hidden identifiers for the built-in callouts. A row carrying one of
+ # these was seeded from a code-defined default (see BuiltinCallouts) and
# keeps its ticket behavior — badges, per-registration visibility — driven by
- # that key. Admin-authored callouts have a nil magic_key. Magic callouts are
+ # that key. Admin-authored callouts have a nil builtin_key. Built-in callouts are
# hidden rather than destroyed so they can be restored to their default.
- MAGIC_KEYS = %w[
+ BUILTIN_KEYS = %w[
payment certificate scholarship ce_hours event_details
videoconference handouts faq
].freeze
- # "Content" magic callouts render their own editable copy/resources (like custom
- # callouts). "Behavioral" magic callouts (the rest) render live per-registration
- # status through MagicTicketCallouts#card_for — the row still owns the editable
+ # "Content" built-in callouts render their own editable copy/resources (like custom
+ # callouts). "Behavioral" built-in callouts (the rest) render live per-registration
+ # status through BuiltinCalloutCards#card_for — the row still owns the editable
# title/subtitle/text, order, visibility, and resources.
- CONTENT_MAGIC_KEYS = %w[ handouts faq ].freeze
+ CONTENT_BUILTIN_KEYS = %w[ handouts faq ].freeze
# Behavioral built-ins that also carry event-level config edited inline in their
# row (CE hours offered / cost); their text lives on the row like everything else.
- CONFIG_MAGIC_KEYS = %w[ ce_hours ].freeze
+ CONFIG_BUILTIN_KEYS = %w[ ce_hours ].freeze
# Built-ins whose card colour the app sets from live status — Payment turns
# orange while a balance is due, and Scholarship / CE hours turn amber while the
# registrant has something outstanding. For these, the app colour overrides the
- # selected one (see MagicTicketCallouts#card_for).
- APP_COLORED_MAGIC_KEYS = %w[ payment scholarship ce_hours ].freeze
+ # selected one (see BuiltinCalloutCards#card_for).
+ APP_COLORED_BUILTIN_KEYS = %w[ payment scholarship ce_hours ].freeze
# Per-type fallbacks for the icon and colour. These are callout-specific (unlike
# the generic colour swatches and palette, which live in DomainTheme so the whole
@@ -69,13 +69,13 @@ class RegistrationTicketCallout < ApplicationRecord
validates :callout_type, inclusion: { in: CALLOUT_TYPES }
validates :color_class, inclusion: { in: DomainTheme::SWATCH_COLORS.map(&:to_s) }, allow_blank: true
validates :position, numericality: { only_integer: true, greater_than: 0, allow_nil: true }
- validates :magic_key, inclusion: { in: MAGIC_KEYS }, allow_nil: true
- validates :magic_key, uniqueness: { scope: :event_id }, allow_nil: true
+ validates :builtin_key, inclusion: { in: BUILTIN_KEYS }, allow_nil: true
+ validates :builtin_key, uniqueness: { scope: :event_id }, allow_nil: true
scope :ordered, -> { order(:position, :id) }
scope :visible, -> { where(hidden: false) }
- scope :magic, -> { where.not(magic_key: nil) }
- scope :custom, -> { where(magic_key: nil) }
+ scope :builtin, -> { where.not(builtin_key: nil) }
+ scope :custom, -> { where(builtin_key: nil) }
# Reset flagged rows to their template after the save that set the flag. The
# flag is cleared first so the reset's own update doesn't recurse.
@@ -83,7 +83,7 @@ class RegistrationTicketCallout < ApplicationRecord
def apply_reset_to_default
self.reset_to_default = nil
- DefaultTicketCallouts.reset(self)
+ BuiltinCallouts.reset(self)
end
def action?
@@ -107,30 +107,30 @@ def published=(value)
attr_accessor :reset_to_default
def reset_to_default?
- magic? && ActiveModel::Type::Boolean.new.cast(reset_to_default)
+ builtin? && ActiveModel::Type::Boolean.new.cast(reset_to_default)
end
# A seeded built-in callout (Handouts, FAQ, …) rather than an admin-authored
- # one. Magic callouts hide instead of delete and can be reset to default.
- def magic?
- magic_key.present?
+ # one. Built-in callouts hide instead of delete and can be reset to default.
+ def builtin?
+ builtin_key.present?
end
- # A behavioral built-in callout whose card is rendered by MagicTicketCallouts
+ # A behavioral built-in callout whose card is rendered by BuiltinCalloutCards
# (live status), as opposed to a content callout that renders from its own row.
- def behavioral_magic?
- magic? && CONTENT_MAGIC_KEYS.exclude?(magic_key)
+ def behavioral_builtin?
+ builtin? && CONTENT_BUILTIN_KEYS.exclude?(builtin_key)
end
# Whether the row carries the inline CE config fields (hours offered / cost).
def ce_config?
- CONFIG_MAGIC_KEYS.include?(magic_key.to_s)
+ CONFIG_BUILTIN_KEYS.include?(builtin_key.to_s)
end
# Whether the app sets this card's colour from live status, overriding the
# selected colour (so the editor notes it under the colour picker).
def app_colored?
- APP_COLORED_MAGIC_KEYS.include?(magic_key.to_s)
+ APP_COLORED_BUILTIN_KEYS.include?(builtin_key.to_s)
end
# Whether the callout's own page has content to show — its editable text or any
@@ -144,7 +144,7 @@ def page_content?
# so the manual visibility controls (drip date, payment gate) don't apply — the
# editor hides its Visibility section.
def payment?
- magic_key == "payment"
+ builtin_key == "payment"
end
# Whether the callout's page content is drip-scheduled to reveal only from a
diff --git a/app/models/registration_ticket_callout_resource.rb b/app/models/registration_ticket_callout_resource.rb
index b01afac64..fb3f5797a 100644
--- a/app/models/registration_ticket_callout_resource.rb
+++ b/app/models/registration_ticket_callout_resource.rb
@@ -6,7 +6,7 @@ class RegistrationTicketCalloutResource < ApplicationRecord
# `subtitle` is the short line shown on the callout card for this resource;
# `page_content` is the longer copy shown under the title on the resource's own
# detail page. Both are editable per event (materialized from
- # DefaultTicketCallouts for the built-in handouts).
+ # BuiltinCallouts for the built-in handouts).
belongs_to :registration_ticket_callout
belongs_to :resource
diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb
index 4ee664e3d..a081a54f7 100644
--- a/app/policies/event_policy.rb
+++ b/app/policies/event_policy.rb
@@ -161,7 +161,7 @@ def google_analytics?
sector_ids: [],
primary_asset_attributes: [ :id, :file, :_destroy ],
gallery_assets_attributes: [ :id, :file, :_destroy ],
- registration_ticket_callouts_attributes: [ :id, :title, :subtitle, :description, :callout_type, :icon_class, :color_class, :display_from, :payment_access_gated, :published, :reset_to_default, :_destroy,
+ registration_ticket_callouts_attributes: [ :id, :builtin_key, :title, :subtitle, :description, :callout_type, :icon_class, :color_class, :display_from, :payment_access_gated, :published, :reset_to_default, :_destroy,
{ registration_ticket_callout_resources_attributes: [ :id, :resource_id, :subtitle, :page_content, :_destroy ] } ]
]
diff --git a/app/services/magic_ticket_callouts.rb b/app/services/builtin_callout_cards.rb
similarity index 82%
rename from app/services/magic_ticket_callouts.rb
rename to app/services/builtin_callout_cards.rb
index 02c377948..88346c126 100644
--- a/app/services/magic_ticket_callouts.rb
+++ b/app/services/builtin_callout_cards.rb
@@ -1,13 +1,20 @@
-# "Magic" ticket callouts are code-defined cards on a registration ticket whose
-# presence and content the app controls — unlike admin-configured
-# RegistrationTicketCallouts, which admins create and reorder. Each magic card
-# knows its own visibility rule (e.g. the payment card switches between an action
-# and a reference card depending on whether a balance is due) and where it links.
+# Renders the live, per-registration cards for a registrant's ticket. Given an
+# EventRegistration, it turns each built-in callout into a display Card whose
+# dynamic parts depend on that registration — the Payment card's balance and
+# colour, the Certificate card only once it's unlocked, the Scholarship badge, and
+# so on. Each card exposes the _callout_card partial's interface
+# (#display_icon_class, #theme, #title, #subtitle) plus #href / #target /
+# #trailing_icon.
#
-# They render through the same _callout_card partial as admin callouts, so each
-# card exposes that partial's presentation interface (#display_icon_class,
-# #theme, #title, #subtitle) plus its own #href / #target / #trailing_icon.
-class MagicTicketCallouts
+# `#card_for(row)` overlays this live status onto a materialized built-in row: the
+# row supplies the admin-edited title/subtitle/colour, this service supplies the
+# badge, status colour, visibility guard, and destination. `#cards` is the code
+# fallback for events whose built-ins aren't materialized yet. `.editor_cards`
+# builds the greyed preview cards shown in the event editor.
+#
+# The callouts' definitions and materialization live in BuiltinCallouts; this
+# service is only about how they render on a ticket.
+class BuiltinCalloutCards
include Rails.application.routes.url_helpers
# `badge` / `badge_classes` are an optional status chip shown inline in the
@@ -26,12 +33,12 @@ def theme = DomainTheme.swatch(color)
# A registration-free description of one built-in card, for the event editor's
# callouts section. `key` is :ce_hours / :event_details for the two whose text
# admins edit via event columns; nil for the cards the app fully controls (shown
- # greyed out). `magic_key` ties the card to its ticket behavior — once an event
+ # greyed out). `builtin_key` ties the card to its ticket behavior — once an event
# has materialized that key into an editable row, the preview is dropped here and
# the row is edited in the callout list instead. `subtitle` mirrors the card's
# ticket subtitle; `visibility` describes when the app shows it (rendered next to
# the "Built in" chip); `note` is an optional hint on where content comes from.
- EditorCard = Data.define(:key, :magic_key, :icon_class, :color, :title, :subtitle, :visibility, :note) do
+ EditorCard = Data.define(:key, :builtin_key, :icon_class, :color, :title, :subtitle, :visibility, :note) do
def theme = DomainTheme.swatch(color)
def editable? = key.present?
end
@@ -41,7 +48,9 @@ def editable? = key.present?
# materialized are omitted — they're edited as real rows in the callout list.
# Keep in sync with #cards: add a card method, add it here.
def self.editor_cards(event)
- materialized = event.registration_ticket_callouts.magic.pluck(:magic_key).to_set
+ # Read the loaded/in-memory rows (not a DB pluck) so a new event's just-built,
+ # not-yet-saved built-in rows count as materialized and aren't also previewed.
+ materialized = event.registration_ticket_callouts.reject(&:marked_for_destruction?).filter_map(&:builtin_key).to_set
[
EditorCard.new(nil, "payment", "fa-solid fa-credit-card", "orange", "Payment", "Your balance and payment history", "When the event has a cost", nil),
EditorCard.new(nil, "certificate", "fa-solid fa-certificate", "green", "Certificate of completion", "View and download your certificate", "Once the certificate is unlocked", nil),
@@ -49,19 +58,20 @@ def self.editor_cards(event)
EditorCard.new(nil, "videoconference", "fa-solid fa-video", "blue", "Videoconference", "Join link and how to add it to your calendar", "When the event has a videoconference link", "Details come from this event's videoconference settings."),
EditorCard.new(nil, "handouts", "fa-solid fa-folder-open", "blue", "Handouts", "Worksheets and resources for the training", "On facilitator trainings", "Items link to their relevant resources."),
EditorCard.new(nil, "faq", "fa-solid fa-circle-question", "blue", "Frequently asked questions", "Common questions about the 2-day training", "On facilitator trainings", nil)
- ].reject { |card| card.key.nil? && materialized.include?(card.magic_key) }
+ ].reject { |card| card.key.nil? && materialized.include?(card.builtin_key) }
end
- # magic_key → builder method, in the default order cards appear on a ticket.
+ # builtin_key → builder method, in the default order cards appear on a ticket.
+ # Handouts and FAQ are pure content cards: they only ever render from their
+ # materialized row (the ticket's content branch), never from code, so they
+ # have no builder here.
CARD_BUILDERS = {
"payment" => :payment_card,
"certificate" => :certificate_card,
"scholarship" => :scholarship_status_card,
"ce_hours" => :ce_hours_card,
"event_details" => :event_details_card,
- "videoconference" => :videoconference_card,
- "handouts" => :handouts_card,
- "faq" => :faq_card
+ "videoconference" => :videoconference_card
}.freeze
def initialize(event_registration)
@@ -74,7 +84,7 @@ def initialize(event_registration)
# those from the row (calling #card_for for behavioral ones), so this is both the
# non-materialized set and the fallback for events not yet seeded.
def cards
- CARD_BUILDERS.reject { |magic_key, _| materialized?(magic_key) || skip_in_fallback?(magic_key) }
+ CARD_BUILDERS.reject { |builtin_key, _| materialized?(builtin_key) || skip_in_fallback?(builtin_key) }
.filter_map { |_, builder| send(builder) }
end
@@ -84,11 +94,11 @@ def cards
# destination link); the row supplies the editable presentation (title,
# subtitle, colour, icon), so admin edits to those take effect on the ticket.
def card_for(callout)
- builder = CARD_BUILDERS[callout.magic_key]
+ builder = CARD_BUILDERS[callout.builtin_key]
base = builder && send(builder)
return unless base
# Event details links to its page only when it has content to show.
- return if callout.magic_key == "event_details" && callout.description.blank?
+ return if callout.builtin_key == "event_details" && callout.description.blank?
base.with(
title: callout.title,
@@ -106,15 +116,15 @@ def card_for(callout)
# 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?(magic_key)
- @materialized_keys ||= event.registration_ticket_callouts.magic.pluck(:magic_key).to_set
- @materialized_keys.include?(magic_key)
+ def materialized?(builtin_key)
+ @materialized_keys ||= event.registration_ticket_callouts.builtin.pluck(:builtin_key).to_set
+ @materialized_keys.include?(builtin_key)
end
# In the unseeded fallback, event-details content lives on the event column, so
# hide the card when it's blank (the row path checks the row in #card_for).
- def skip_in_fallback?(magic_key)
- magic_key == "event_details" && event.event_details.blank?
+ def skip_in_fallback?(builtin_key)
+ builtin_key == "event_details" && event.event_details.blank?
end
# Top card: an action card while a balance is due, a reference card once paid
@@ -261,26 +271,4 @@ def videoconference_card
href: registration_videoconference_path(registration.slug),
target: nil, trailing_icon: "fa-solid fa-arrow-right")
end
-
- # Links to the 2-day training worksheets and handouts, so shown only on
- # facilitator trainings — see Event#show_handouts_callout?.
- def handouts_card
- return unless event.show_handouts_callout?
- Card.new(icon_class: "fa-solid fa-folder-open", color: "blue",
- title: "Handouts",
- subtitle: "Worksheets and resources for the training",
- href: registration_handouts_path(registration.slug),
- target: nil, trailing_icon: "fa-solid fa-arrow-right")
- end
-
- # Reference card linking to the 2-day training FAQ page, so shown only on
- # facilitator trainings — see Event#show_faq_callout?.
- def faq_card
- return unless event.show_faq_callout?
- Card.new(icon_class: "fa-solid fa-circle-question", color: "blue",
- title: "Frequently asked questions",
- subtitle: "Common questions about the 2-day training",
- href: registration_faq_path(registration.slug),
- target: nil, trailing_icon: "fa-solid fa-arrow-right")
- end
end
diff --git a/app/services/default_ticket_callouts.rb b/app/services/builtin_callouts.rb
similarity index 80%
rename from app/services/default_ticket_callouts.rb
rename to app/services/builtin_callouts.rb
index 90da5e16b..eb7dab097 100644
--- a/app/services/default_ticket_callouts.rb
+++ b/app/services/builtin_callouts.rb
@@ -1,19 +1,19 @@
-# Materializes the built-in ("magic") ticket callouts into editable rows for an
-# event. Run on event create and, for events that predate this, lazily on edit —
-# so existing events heal the first time they're saved, with no data backfill.
+# The catalog of built-in ticket callouts (Payment, Certificate, Scholarship, CE
+# hours, Event details, Videoconference, Handouts, FAQ) and the code that
+# materializes them onto an event as editable RegistrationTicketCallout rows:
+# `seed` persists them (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; `reset`
+# restores a row to its default; `customized?` reports whether a row was edited.
#
-# All eight built-ins always seed. Their initial visibility (hidden/published) is
-# derived from the event's config via each definition's `hidden` proc: published
-# by default on facilitator trainings, hidden (unchecked) on everything else.
-# Admins toggle visibility per row from the editor. Seeding is idempotent so a
-# re-run never clobbers admin edits.
+# All eight seed hidden (unchecked) by default — admins publish the ones they want
+# per event, edit them per row, and can restore a row to default. Custom callouts
+# live in the same list and can be reordered among the built-ins. Seeding is
+# idempotent so a re-run never clobbers admin edits.
#
-# Behavioral built-ins (payment, CE, scholarship, …) render live per-registration
-# status through MagicTicketCallouts#card_for, which has its own guards (e.g. the
-# payment card returns nil on a free event) so publishing a row on an
-# non-applicable event is harmless. MagicTicketCallouts skips any card an event
-# has already materialized, so the two paths never double-render.
-class DefaultTicketCallouts
+# This service owns the callouts' *definitions and materialization*. How a callout
+# then renders as a live card on a registrant's ticket (status badges, per-
+# registration guards) is the job of BuiltinCalloutCards.
+class BuiltinCallouts
# Default FAQ content for the 2-day training, mirrored from the code-defined FAQ
# page so a newly materialized card starts with the current copy. Admins edit it
# per event afterward.
@@ -82,15 +82,26 @@ class DefaultTicketCallouts
}
}.freeze
- # magic_keys this service knows how to materialize.
+ # builtin_keys this service knows how to materialize.
def self.seedable_keys
- new(nil).send(:definitions).map { |definition| definition[:magic_key] }
+ new(nil).send(:definitions).map { |definition| definition[:builtin_key] }
end
def self.seed(event)
new(event).seed
end
+ # Build (not persist) any not-yet-present built-in callouts as in-memory rows on
+ # the event, so a brand-new event's form shows the built-ins as editable rows
+ # that save together with the event on first submit. Idempotent against rows
+ # already loaded/built (e.g. re-rendering a failed create), so it never
+ # duplicates. Persisting still routes each row's `builtin_key` back through nested
+ # attributes; the post-save `seed` is the idempotent safety net for non-form
+ # creation paths. Returns the built rows.
+ def self.build(event)
+ new(event).build
+ end
+
# Reset a materialized callout's content and default visibility back to its
# built-in template, keeping its position. Used by the "Restore default" action.
def self.reset(callout)
@@ -107,19 +118,27 @@ def initialize(event)
@event = event
end
- # Create any not-yet-present magic callouts for the event, appended in
+ # Create any not-yet-present built-in callouts for the event, appended in
# definition order after whatever already exists. All built-ins always seed;
# their initial visibility (hidden/published) is derived from the event's
# config via each definition's `hidden` proc. Seeding is idempotent so a re-run
# never clobbers admin edits. Returns the created rows.
def seed
- existing_keys = @event.registration_ticket_callouts.magic.pluck(:magic_key).to_set
- definitions.reject { |definition| existing_keys.include?(definition[:magic_key]) }
+ existing_keys = @event.registration_ticket_callouts.builtin.pluck(:builtin_key).to_set
+ definitions.reject { |definition| existing_keys.include?(definition[:builtin_key]) }
.map { |definition| create(definition) }
end
+ # In-memory counterpart to #seed. Skips keys already present on the loaded
+ # association (built or persisted) so it's safe to call on every form render.
+ def build
+ existing_keys = @event.registration_ticket_callouts.reject(&:marked_for_destruction?).filter_map(&:builtin_key).to_set
+ definitions.reject { |definition| existing_keys.include?(definition[:builtin_key]) }
+ .map { |definition| build_row(definition) }
+ end
+
def reset(callout)
- definition = definitions.find { |candidate| candidate[:magic_key] == callout.magic_key }
+ definition = definitions.find { |candidate| candidate[:builtin_key] == callout.builtin_key }
return callout unless definition
callout.update!(
@@ -138,7 +157,7 @@ def reset(callout)
end
def customized?(callout)
- definition = definitions.find { |candidate| candidate[:magic_key] == callout.magic_key }
+ definition = definitions.find { |candidate| candidate[:builtin_key] == callout.builtin_key }
return false unless definition
callout.title != resolve(definition[:title]) ||
@@ -165,18 +184,18 @@ def resolve(value)
# each event derives its own defaults; `resources` resolves the linked records;
# `seed_if` gates whether the card applies. Content cards (Handouts, FAQ) render
# their own copy; "behavioral" cards (Certificate, Videoconference) render live
- # per-registration status through MagicTicketCallouts#card_for — the row only
+ # per-registration status through BuiltinCalloutCards#card_for — the row only
# governs visibility, drip date, and order.
def definitions
[
{
- magic_key: "payment",
+ builtin_key: "payment",
title: "Payment",
subtitle: "Your balance and payment history",
callout_type: "action",
icon_class: "fa-solid fa-credit-card",
color_class: "orange",
- hidden: ->(event) { !event.facilitator_training? },
+ hidden: ->(_event) { true },
# The W-9 is a removable linked resource, included by default only on paid
# events (where a tax form applies); the invoice/receipt stay dynamic on
# the payment page. Admins add/remove it per event.
@@ -187,27 +206,27 @@ def definitions
}
},
{
- magic_key: "certificate",
+ builtin_key: "certificate",
title: "Certificate of completion",
subtitle: "View and download your certificate",
callout_type: "action",
icon_class: "fa-solid fa-certificate",
color_class: "green",
# Off by default except on facilitator trainings. When shown, it still only
- # appears once the certificate unlocks (MagicTicketCallouts guards this).
- hidden: ->(event) { !event.facilitator_training? }
+ # appears once the certificate unlocks (BuiltinCalloutCards guards this).
+ hidden: ->(_event) { true }
},
{
- magic_key: "scholarship",
+ builtin_key: "scholarship",
title: "Scholarship",
subtitle: "Your scholarship request and award",
callout_type: "action",
icon_class: "fa-solid fa-award",
color_class: "fuchsia",
- hidden: ->(event) { !event.facilitator_training? }
+ hidden: ->(_event) { true }
},
{
- magic_key: "ce_hours",
+ builtin_key: "ce_hours",
# Title/text seed from the event's CE columns (migrating existing content);
# thereafter they live on the row like every other built-in. The row also
# carries the CE hours-offered/cost config.
@@ -217,10 +236,10 @@ def definitions
callout_type: "action",
icon_class: "fa-solid fa-graduation-cap",
color_class: "teal",
- hidden: ->(event) { !event.facilitator_training? }
+ hidden: ->(_event) { true }
},
{
- magic_key: "event_details",
+ builtin_key: "event_details",
# Title/text seed from the event's details columns (migrating existing
# content); thereafter they live on the row.
title: ->(event) { event.event_details_label },
@@ -229,48 +248,62 @@ def definitions
callout_type: "reference",
icon_class: "fa-solid fa-palette",
color_class: "blue",
- hidden: ->(event) { !event.facilitator_training? }
+ hidden: ->(_event) { true }
},
{
- magic_key: "videoconference",
+ builtin_key: "videoconference",
title: "Videoconference",
subtitle: "Join link and how to add it to your calendar",
callout_type: "action",
icon_class: "fa-solid fa-video",
color_class: "blue",
- hidden: ->(event) { !event.facilitator_training? },
+ hidden: ->(_event) { true },
# Drips onto the ticket a week before the event starts, replacing the old
# hard-coded "one week prior" rule with a stored, editable date.
display_from: ->(event) { event.start_date - 7.days if event.start_date }
},
{
- magic_key: "handouts",
+ builtin_key: "handouts",
title: "Handouts",
subtitle: "Worksheets and resources for the training",
callout_type: "reference",
icon_class: "fa-solid fa-folder-open",
color_class: "blue",
- hidden: ->(event) { !event.facilitator_training? },
+ hidden: ->(_event) { true },
resources: -> { handout_resources },
# Per-link subtitle/page_content defaults, keyed by resource title.
resource_content: HANDOUT_LINK_DEFAULTS
},
{
- magic_key: "faq",
+ builtin_key: "faq",
title: "Frequently asked questions",
subtitle: "Common questions about the 2-day training",
callout_type: "reference",
icon_class: "fa-solid fa-circle-question",
color_class: "blue",
description: self.class.faq_html,
- hidden: ->(event) { !event.facilitator_training? }
+ hidden: ->(_event) { true }
}
]
end
def create(definition)
- callout = @event.registration_ticket_callouts.create!(
- magic_key: definition[:magic_key],
+ callout = @event.registration_ticket_callouts.create!(attributes_for(definition))
+ build_resource_links(callout, definition)
+ callout
+ end
+
+ # In-memory sibling of #create: builds the row (and its resource links) without
+ # persisting, so they save as nested attributes when the event is saved.
+ def build_row(definition)
+ callout = @event.registration_ticket_callouts.build(attributes_for(definition))
+ build_resource_links(callout, definition)
+ callout
+ end
+
+ def attributes_for(definition)
+ {
+ builtin_key: definition[:builtin_key],
title: resolve(definition[:title]),
subtitle: resolve(definition[:subtitle]),
description: resolve(definition[:description]),
@@ -279,23 +312,24 @@ def create(definition)
color_class: definition[:color_class],
hidden: definition[:hidden].call(@event),
display_from: definition[:display_from]&.call(@event)
- )
- build_resource_links(callout, definition)
- callout
+ }
end
# Link the definition's resources in order, materializing each join row's
# subtitle/page_content from `resource_content` (keyed by resource title) when
- # the definition supplies it. The positioning gem assigns each row's position.
+ # the definition supplies it. Links persist immediately for a saved callout, or
+ # stay in memory (saved with the event) when the callout is still unsaved. The
+ # positioning gem assigns each row's position.
def build_resource_links(callout, definition)
content = definition[:resource_content] || {}
Array(definition[:resources]&.call).each do |resource|
attrs = content[resource.title] || {}
- callout.registration_ticket_callout_resources.create!(
+ link = callout.registration_ticket_callout_resources.build(
resource: resource,
subtitle: attrs[:subtitle],
page_content: attrs[:page_content]
)
+ link.save! if callout.persisted?
end
end
diff --git a/app/views/event_registrations/_callout_card.html.erb b/app/views/event_registrations/_callout_card.html.erb
index e797d2987..5e69fe530 100644
--- a/app/views/event_registrations/_callout_card.html.erb
+++ b/app/views/event_registrations/_callout_card.html.erb
@@ -1,9 +1,9 @@
<%# Renders one registration ticket callout card. Used for both admin-configured
- RegistrationTicketCallouts and code-defined MagicTicketCallouts::Card objects.
+ RegistrationTicketCallouts and code-defined BuiltinCalloutCards::Card objects.
locals: callout (responds to #theme, #display_icon_class, #title, #subtitle).
href / target / trailing_icon may be passed explicitly (admin callouts
compute href from a route) or read off the callout when it carries them
- (MagicTicketCallouts::Card).
+ (BuiltinCalloutCards::Card).
linked (default true): when false the card renders as a non-navigating
div — the sample-ticket preview has no real per-registration pages. %>
<% theme = callout.theme %>
@@ -14,7 +14,7 @@
<% badge = callout.try(:badge) %>
<% badge_classes = callout.try(:badge_classes).presence || "bg-amber-100 text-amber-800 border border-amber-300" %>
<%# A card leads to a page — so it links and shows the trailing "go to page"
- arrow — always for magic cards (they carry their own page), and for admin
+ arrow — always for built-in cards (they carry their own page), and for admin
callouts only when they have page content of their own. Content-less callouts
render as a plain, non-navigating card. %>
<% links_to_page = callout.respond_to?(:page_content?) ? callout.page_content? : true %>
diff --git a/app/views/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb
index 033cb9043..4d351e6c8 100644
--- a/app/views/event_registrations/_ticket.html.erb
+++ b/app/views/event_registrations/_ticket.html.erb
@@ -100,26 +100,26 @@
<% end %>
- <% magic = MagicTicketCallouts.new(event_registration) %>
+ <% builtin_cards = BuiltinCalloutCards.new(event_registration) %>
-
- <% magic.cards.each do |card| %>
+ <% builtin_cards.cards.each do |card| %>
<%= render "event_registrations/callout_card", callout: card, linked: !preview %>
<% end %>
<% payment_access = event_registration.payment_access_granted? || @checkout_payment_status == "paid" %>
<% event_registration.event.registration_ticket_callouts.visible.each do |callout| %>
<% next if callout.payment_access_gated && !payment_access %>
- <% if callout.behavioral_magic? %>
- <% card = magic.card_for(callout) %>
+ <% if callout.behavioral_builtin? %>
+ <% card = builtin_cards.card_for(callout) %>
<% if card %>
<%= render "event_registrations/callout_card", callout: card, linked: !preview %>
<% end %>
diff --git a/app/views/events/_builtin_callout_card.html.erb b/app/views/events/_builtin_callout_card.html.erb
index 77b4e6f7d..ef198b877 100644
--- a/app/views/events/_builtin_callout_card.html.erb
+++ b/app/views/events/_builtin_callout_card.html.erb
@@ -1,7 +1,7 @@
<%# An editable built-in registration-ticket callout (CE hours, art supplies),
rendered inside the event form's callouts section and tinted in the colour the
card uses on the ticket. locals: f (event form builder), card
- (MagicTicketCallouts::EditorCard), label_field/content_field (event attribute
+ (BuiltinCalloutCards::EditorCard), label_field/content_field (event attribute
symbols), title_placeholder, content_help, content_placeholder. %>
<% theme = card.theme %>
- <% MagicTicketCallouts.editor_cards(@event).each do |card| %>
+ <% BuiltinCalloutCards.editor_cards(@event).each do |card| %>
<% case card.key %>
<% when :ce_hours %>
<%= render "events/builtin_callout_card", f: f, card: card,
diff --git a/app/views/events/_registration_ticket_callout_fields.html.erb b/app/views/events/_registration_ticket_callout_fields.html.erb
index 9743f4580..7da76d907 100644
--- a/app/views/events/_registration_ticket_callout_fields.html.erb
+++ b/app/views/events/_registration_ticket_callout_fields.html.erb
@@ -21,6 +21,12 @@
<% end %>
+ <%# Round-trips the built-in identity so an unsaved (new-event) built-in row saves
+ as its built-in, not a custom callout; kept outside the collapsed content
+ so it submits even when the row is unpublished. %>
+ <% if f.object.builtin? %>
+ <%= f.hidden_field :builtin_key %>
+ <% end %>
<%#
Published toggle, pinned to the card's top-right corner. The checkbox is
kept as a bare sibling *before* the content div below so the
@@ -40,7 +46,7 @@
%>
- <% if f.object.magic? %>
+ <% if f.object.builtin? %>
Built in
<% end %>
@@ -223,8 +229,8 @@
the same spot instead.
%>
- <% if f.object.magic? %>
- <% if f.object.persisted? && DefaultTicketCallouts.customized?(f.object) %>
+ <% if f.object.builtin? %>
+ <% if f.object.persisted? && BuiltinCallouts.customized?(f.object) %>
<% else %>
Matches default
diff --git a/db/migrate/20260715041845_rename_magic_key_to_builtin_key.rb b/db/migrate/20260715041845_rename_magic_key_to_builtin_key.rb
new file mode 100644
index 000000000..ec0bd2440
--- /dev/null
+++ b/db/migrate/20260715041845_rename_magic_key_to_builtin_key.rb
@@ -0,0 +1,17 @@
+class RenameMagicKeyToBuiltinKey < ActiveRecord::Migration[8.1]
+ # "magic_key" was the hidden identifier tying a materialized row back to its
+ # built-in. Renamed to "builtin_key" so the whole built-in callout stack reads
+ # consistently (BuiltinCallouts / BuiltinCalloutCards / builtin_key).
+ OLD_INDEX = "index_registration_ticket_callouts_on_event_id_and_magic_key"
+ NEW_INDEX = "index_registration_ticket_callouts_on_event_id_and_builtin_key"
+
+ def up
+ rename_column :registration_ticket_callouts, :magic_key, :builtin_key
+ rename_index :registration_ticket_callouts, OLD_INDEX, NEW_INDEX if index_name_exists?(:registration_ticket_callouts, OLD_INDEX)
+ end
+
+ def down
+ rename_index :registration_ticket_callouts, NEW_INDEX, OLD_INDEX if index_name_exists?(:registration_ticket_callouts, NEW_INDEX)
+ rename_column :registration_ticket_callouts, :builtin_key, :magic_key
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 367f5f508..39ed76ab0 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_07_15_000553) do
+ActiveRecord::Schema[8.1].define(version: 2026_07_15_041845) do
create_table "action_text_mentions", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
t.bigint "action_text_rich_text_id", null: false
t.datetime "created_at", null: false
@@ -1111,6 +1111,7 @@
end
create_table "registration_ticket_callouts", charset: "utf8mb4", collation: "utf8mb4_unicode_ci", force: :cascade do |t|
+ t.string "builtin_key"
t.string "callout_type", default: "reference", null: false
t.string "color_class"
t.datetime "created_at", null: false
@@ -1119,13 +1120,12 @@
t.bigint "event_id", null: false
t.boolean "hidden", default: false, null: false
t.string "icon_class"
- t.string "magic_key"
t.boolean "payment_access_gated", default: false, null: false
t.integer "position", null: false
t.string "subtitle"
t.string "title", null: false
t.datetime "updated_at", null: false
- t.index ["event_id", "magic_key"], name: "index_registration_ticket_callouts_on_event_id_and_magic_key", unique: true
+ t.index ["event_id", "builtin_key"], name: "index_registration_ticket_callouts_on_event_id_and_builtin_key", unique: true
t.index ["event_id", "position"], name: "index_registration_ticket_callouts_on_event_id_and_position"
t.index ["event_id"], name: "index_registration_ticket_callouts_on_event_id"
end
diff --git a/spec/factories/registration_ticket_callouts.rb b/spec/factories/registration_ticket_callouts.rb
index e6e64b61a..9111952aa 100644
--- a/spec/factories/registration_ticket_callouts.rb
+++ b/spec/factories/registration_ticket_callouts.rb
@@ -33,8 +33,8 @@
hidden { true }
end
- trait :magic do
- magic_key { "faq" }
+ trait :builtin do
+ builtin_key { "faq" }
title { "Frequently asked questions" }
end
end
diff --git a/spec/models/event_registration_spec.rb b/spec/models/event_registration_spec.rb
index f77363e4e..156082173 100644
--- a/spec/models/event_registration_spec.rb
+++ b/spec/models/event_registration_spec.rb
@@ -664,7 +664,7 @@ def registration_with_scholarship
it "is visible immediately once paid when the callout has no drip date" do
event = create(:event, cost_cents: 1099, start_date: 30.days.from_now, end_date: 30.days.from_now + 2.hours)
- create(:registration_ticket_callout, event:, magic_key: "videoconference", display_from: nil)
+ create(:registration_ticket_callout, event:, builtin_key: "videoconference", display_from: nil)
reg = create(:event_registration, event: event, registrant: user.person, intends_to_pay: true)
expect(reg.videoconference_details_visible?).to be true
end
diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb
index e84b3a459..f5a71f6e8 100644
--- a/spec/models/event_spec.rb
+++ b/spec/models/event_spec.rb
@@ -111,7 +111,7 @@
it "defers to the videoconference callout's drip date over the week-before default" do
event = create(:event, start_date: 6.days.from_now, end_date: 6.days.from_now + 2.hours)
- create(:registration_ticket_callout, event:, magic_key: "videoconference",
+ create(:registration_ticket_callout, event:, builtin_key: "videoconference",
display_from: 2.days.from_now)
# Within the week-before default (which would be true), but before the
@@ -121,7 +121,7 @@
it "is visible immediately when the callout's drip date has been cleared" do
event = create(:event, start_date: 6.days.from_now, end_date: 6.days.from_now + 2.hours)
- create(:registration_ticket_callout, event:, magic_key: "videoconference", display_from: nil)
+ create(:registration_ticket_callout, event:, builtin_key: "videoconference", display_from: nil)
expect(event.videoconference_details_visible?).to be true
end
@@ -323,18 +323,6 @@
end
end
- describe "built-in ticket callout visibility" do
- it "shows Handouts and FAQ only for facilitator trainings" do
- training = build(:event, facilitator_training: true)
- other = build(:event, facilitator_training: false)
-
- expect(training.show_handouts_callout?).to be true
- expect(training.show_faq_callout?).to be true
- expect(other.show_handouts_callout?).to be false
- expect(other.show_faq_callout?).to be false
- end
- end
-
describe '.search_by_params' do
let!(:art_event) { create(:event, title: 'Art Workshop Showcase', description: 'Annual art exhibition') }
let!(:music_event) { create(:event, title: 'Music Therapy Session', description: 'Healing through music') }
diff --git a/spec/models/registration_ticket_callout_spec.rb b/spec/models/registration_ticket_callout_spec.rb
index 3e62bf495..24cad46d8 100644
--- a/spec/models/registration_ticket_callout_spec.rb
+++ b/spec/models/registration_ticket_callout_spec.rb
@@ -28,38 +28,38 @@
expect(callout).not_to be_valid
end
- it "rejects an unknown magic_key" do
- callout.magic_key = "bogus"
+ it "rejects an unknown builtin_key" do
+ callout.builtin_key = "bogus"
expect(callout).not_to be_valid
- expect(callout.errors[:magic_key]).to be_present
+ expect(callout.errors[:builtin_key]).to be_present
end
- it "allows only one callout per magic_key within an event" do
+ it "allows only one callout per builtin_key within an event" do
event = create(:event)
- create(:registration_ticket_callout, event:, magic_key: "faq")
- duplicate = build(:registration_ticket_callout, event:, magic_key: "faq")
+ create(:registration_ticket_callout, event:, builtin_key: "faq")
+ duplicate = build(:registration_ticket_callout, event:, builtin_key: "faq")
expect(duplicate).not_to be_valid
- expect(duplicate.errors[:magic_key]).to be_present
+ expect(duplicate.errors[:builtin_key]).to be_present
end
- it "allows many custom callouts with a nil magic_key" do
+ it "allows many custom callouts with a nil builtin_key" do
event = create(:event)
- create(:registration_ticket_callout, event:, magic_key: nil)
- expect(build(:registration_ticket_callout, event:, magic_key: nil)).to be_valid
+ create(:registration_ticket_callout, event:, builtin_key: nil)
+ expect(build(:registration_ticket_callout, event:, builtin_key: nil)).to be_valid
end
end
describe "scopes and predicates" do
- it "partitions magic and custom callouts and reports #magic?" do
+ it "partitions built-in and custom callouts and reports #builtin?" do
event = create(:event)
- magic = create(:registration_ticket_callout, event:, magic_key: "faq")
- custom = create(:registration_ticket_callout, event:, magic_key: nil)
+ builtin = create(:registration_ticket_callout, event:, builtin_key: "faq")
+ custom = create(:registration_ticket_callout, event:, builtin_key: nil)
- expect(event.registration_ticket_callouts.magic).to eq([ magic ])
+ expect(event.registration_ticket_callouts.builtin).to eq([ builtin ])
expect(event.registration_ticket_callouts.custom).to eq([ custom ])
- expect(magic.magic?).to be(true)
- expect(custom.magic?).to be(false)
+ expect(builtin.builtin?).to be(true)
+ expect(custom.builtin?).to be(false)
end
it "#visible excludes hidden callouts" do
@@ -72,9 +72,9 @@
it "#payment? is true only for the payment built-in" do
event = create(:event)
- payment = create(:registration_ticket_callout, event:, magic_key: "payment")
- other = create(:registration_ticket_callout, event:, magic_key: "faq")
- custom = create(:registration_ticket_callout, event:, magic_key: nil)
+ payment = create(:registration_ticket_callout, event:, builtin_key: "payment")
+ other = create(:registration_ticket_callout, event:, builtin_key: "faq")
+ custom = create(:registration_ticket_callout, event:, builtin_key: nil)
expect(payment.payment?).to be(true)
expect(other.payment?).to be(false)
diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb
index b5cdf2ace..359afa6f7 100644
--- a/spec/requests/events/callouts_spec.rb
+++ b/spec/requests/events/callouts_spec.rb
@@ -3,72 +3,62 @@
RSpec.describe "Events::Callouts", type: :request do
let(:registration) { create(:event_registration, event: event) }
- # These pages are reachable by slug (no login), so the built-in callout
- # visibility rules are the only gate. Hidden callouts redirect back to the
- # ticket so a stray link can't surface training content on a non-training event.
- describe "GET /registration/:slug/faq" do
- context "on a facilitator training" do
- let(:event) { create(:event, facilitator_training: true) }
+ # These pages are reachable by slug (no login), so the built-in callout's
+ # published state is the only gate. A hidden (or not-yet-materialized) callout
+ # redirects back to the ticket so a stray link can't surface it.
+ let(:event) { create(:event) }
- it "renders the FAQ as collapsible toggles from the default content" do
- get registration_faq_path(registration.slug)
- expect(response).to have_http_status(:success)
- expect(response.body).to include("Custom question
Custom answer
")
+ it "renders the editable FAQ callout copy when the card is materialized" do
+ BuiltinCallouts.seed(event)
+ faq = event.registration_ticket_callouts.find_by(builtin_key: "faq")
+ faq.update!(description: "Custom question
Custom answer
", hidden: false)
- get registration_faq_path(registration.slug)
- expect(response.body).to include("Custom question")
- expect(response.body).to include("Custom answer")
- expect(response.body).not_to include("Who is this training designed for?")
- end
+ get registration_faq_path(registration.slug)
+ expect(response.body).to include("Custom question")
+ expect(response.body).to include("Custom answer")
+ expect(response.body).not_to include("Who is this training designed for?")
end
- context "on a non-training event" do
- let(:event) { create(:event, facilitator_training: false) }
-
- it "redirects to the ticket" do
- get registration_faq_path(registration.slug)
- expect(response).to redirect_to(registration_ticket_path(registration.slug))
- end
+ it "redirects to the ticket when the callout is hidden or absent" do
+ get registration_faq_path(registration.slug)
+ expect(response).to redirect_to(registration_ticket_path(registration.slug))
end
end
describe "GET /registration/:slug/handouts" do
- context "on a facilitator training" do
- let(:event) { create(:event, facilitator_training: true) }
-
- it "renders the handouts page" do
- get registration_handouts_path(registration.slug)
- expect(response).to have_http_status(:success)
- end
+ it "renders the handouts page when its callout is published" do
+ create(:registration_ticket_callout, event:, builtin_key: "handouts", hidden: false)
+ get registration_handouts_path(registration.slug)
+ expect(response).to have_http_status(:success)
+ end
- it "shows each linked resource card with its join-row subtitle" do
- callout = create(:registration_ticket_callout, event:, magic_key: "handouts")
- resource = create(:resource, title: "Aha Moments")
- create(:registration_ticket_callout_resource, registration_ticket_callout: callout,
- resource:, subtitle: "Reflect on the workshop", page_content: "Long detail copy.")
+ it "shows each linked resource card with its join-row subtitle" do
+ callout = create(:registration_ticket_callout, event:, builtin_key: "handouts", hidden: false)
+ resource = create(:resource, title: "Aha Moments")
+ create(:registration_ticket_callout_resource, registration_ticket_callout: callout,
+ resource:, subtitle: "Reflect on the workshop", page_content: "Long detail copy.")
- get registration_handouts_path(registration.slug)
+ get registration_handouts_path(registration.slug)
- expect(response.body).to include("Reflect on the workshop")
- # Page content is detail-page only, never on the card.
- expect(response.body).not_to include("Long detail copy.")
- end
+ expect(response.body).to include("Reflect on the workshop")
+ # Page content is detail-page only, never on the card.
+ expect(response.body).not_to include("Long detail copy.")
end
- context "on a non-training event" do
- let(:event) { create(:event, facilitator_training: false) }
-
- it "redirects to the ticket" do
- get registration_handouts_path(registration.slug)
- expect(response).to redirect_to(registration_ticket_path(registration.slug))
- end
+ it "redirects to the ticket when the callout is hidden" do
+ create(:registration_ticket_callout, event:, builtin_key: "handouts", hidden: true)
+ get registration_handouts_path(registration.slug)
+ expect(response).to redirect_to(registration_ticket_path(registration.slug))
end
end
@@ -76,7 +66,7 @@
let(:event) { create(:event, cost_cents: 10_000) }
it "shows the W-9 document's materialized join-row subtitle, not hard-coded copy" do
- callout = create(:registration_ticket_callout, event:, magic_key: "payment")
+ callout = create(:registration_ticket_callout, event:, builtin_key: "payment")
w9 = create(:resource, title: "W-9")
create(:registration_ticket_callout_resource, registration_ticket_callout: callout,
resource: w9, subtitle: "Custom W-9 line")
@@ -97,7 +87,7 @@
it "links resources on the built-in callout to their own page, not inline" do
resource = create(:resource)
create(:downloadable_asset, owner: resource)
- create(:registration_ticket_callout, event:, magic_key: "videoconference",
+ create(:registration_ticket_callout, event:, builtin_key: "videoconference",
title: "Videoconference", resources: [ resource ])
get registration_videoconference_path(registration.slug)
@@ -189,7 +179,7 @@
let(:resource) { create(:resource) }
it "shows the join row's page content below the title" do
- callout = create(:registration_ticket_callout, event:, magic_key: "handouts")
+ callout = create(:registration_ticket_callout, event:, builtin_key: "handouts")
create(:registration_ticket_callout_resource, registration_ticket_callout: callout,
resource:, subtitle: "Short card line", page_content: "The longer page content copy.")
@@ -201,7 +191,7 @@
end
it "shows no page content when the resource isn't linked to the origin callout" do
- create(:registration_ticket_callout, :magic, event:, magic_key: "handouts")
+ create(:registration_ticket_callout, :builtin, event:, builtin_key: "handouts")
get registration_resource_path(registration.slug, resource, return_to: "handouts")
diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb
index 3b48b3016..ac5aabacc 100644
--- a/spec/requests/events/registration_ticket_callouts_spec.rb
+++ b/spec/requests/events/registration_ticket_callouts_spec.rb
@@ -218,7 +218,7 @@
it "resets a callout flagged reset_to_default on the main save" do
training = create(:event, :publicly_visible, facilitator_training: true)
- callout = create(:registration_ticket_callout, event: training, magic_key: "faq", title: "Edited", hidden: true)
+ callout = create(:registration_ticket_callout, event: training, builtin_key: "faq", title: "Edited", hidden: false)
patch event_path(training), params: {
event: {
@@ -230,7 +230,7 @@
}
expect(callout.reload.title).to eq("Frequently asked questions")
- expect(callout.hidden).to be(false)
+ expect(callout.hidden).to be(true) # restored to the built-in default (hidden)
end
end
@@ -242,7 +242,7 @@
event: { title: event.title, start_date: event.start_date, end_date: event.end_date }
}
- expect(event.registration_ticket_callouts.magic.pluck(:magic_key)).to contain_exactly(
+ expect(event.registration_ticket_callouts.builtin.pluck(:builtin_key)).to contain_exactly(
"payment", "certificate", "scholarship", "ce_hours", "event_details",
"videoconference", "handouts", "faq"
)
@@ -253,7 +253,7 @@
before { sign_in admin }
it "renders a materialized content callout as an editable field with a restore checkbox" do
- create(:registration_ticket_callout, event:, magic_key: "faq",
+ create(:registration_ticket_callout, event:, builtin_key: "faq",
title: "Frequently asked questions")
get edit_event_path(event)
@@ -263,8 +263,8 @@
expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][reset_to_default]\"")
end
- it "renders a behavioral magic callout with the same editable fields as a custom one" do
- create(:registration_ticket_callout, event:, magic_key: "certificate",
+ it "renders a behavioral built-in callout with the same editable fields as a custom one" do
+ create(:registration_ticket_callout, event:, builtin_key: "certificate",
title: "Certificate of completion")
get edit_event_path(event)
@@ -279,8 +279,8 @@
it "shows the restore checkbox only once the built-in has been customized" do
# Seed unedited built-ins, then load the editor.
- DefaultTicketCallouts.seed(event)
- faq = event.registration_ticket_callouts.find_by(magic_key: "faq")
+ BuiltinCallouts.seed(event)
+ faq = event.registration_ticket_callouts.find_by(builtin_key: "faq")
get edit_event_path(event)
expect(response.body).to include("Matches default")
@@ -293,7 +293,7 @@
it "gives the Payment card an add-another linked-resource picker" do
resource = create(:resource, title: "W-9")
- create(:registration_ticket_callout, event:, magic_key: "payment", title: "Payment", resources: [ resource ])
+ create(:registration_ticket_callout, event:, builtin_key: "payment", title: "Payment", resources: [ resource ])
get edit_event_path(event)
diff --git a/spec/requests/events/registrations_spec.rb b/spec/requests/events/registrations_spec.rb
index 873211ea6..a2b85ea59 100644
--- a/spec/requests/events/registrations_spec.rb
+++ b/spec/requests/events/registrations_spec.rb
@@ -47,22 +47,24 @@
end
end
- context "magic callouts" do
- # A facilitator training shows the full built-in set, including the
- # training-only Handouts and FAQ cards.
- let(:event) { create(:event, facilitator_training: true) }
+ context "built-in callouts" do
+ # The ticket renders whatever built-in rows are published — no
+ # facilitator-training gate. Seed them all, then publish them.
+ before do
+ BuiltinCallouts.seed(event)
+ event.registration_ticket_callouts.builtin.each { |callout| callout.update!(hidden: false) }
+ end
- it "renders the consolidated magic callout cards" do
+ it "renders the consolidated built-in callout cards for the published built-ins" do
get registration_ticket_path(registration.slug)
- expect(response.body).to include("view your balance")
+ expect(response.body).to include("Your balance and payment history")
expect(response.body).to include("Worksheets and resources for the training")
expect(response.body).to include("Frequently asked questions")
end
- it "omits the training-only Handouts and FAQ cards on a non-training event" do
- non_training = create(:event, facilitator_training: false)
- reg = create(:event_registration, event: non_training, registrant: user.person)
- get registration_ticket_path(reg.slug)
+ it "omits a built-in card once its row is hidden" do
+ event.registration_ticket_callouts.where(builtin_key: %w[handouts faq]).each { |callout| callout.update!(hidden: true) }
+ get registration_ticket_path(registration.slug)
expect(response.body).not_to include("Worksheets and resources for the training")
expect(response.body).not_to include("Frequently asked questions")
end
@@ -261,8 +263,11 @@
end
describe "GET /registration/:slug/faq" do
- let(:event) { create(:event, facilitator_training: true) }
let!(:registration) { create(:event_registration, event: event, registrant: user.person) }
+ before do
+ BuiltinCallouts.seed(event)
+ event.registration_ticket_callouts.find_by(builtin_key: "faq").update!(hidden: false)
+ end
it "renders the training FAQ with the folded-in contact link" do
get registration_faq_path(registration.slug)
@@ -303,7 +308,7 @@
it "links the W-9 to its resource page once a payment is on file, returning to payment" do
w9 = create(:resource, title: "W-9", kind: "Form")
- DefaultTicketCallouts.seed(event)
+ BuiltinCallouts.seed(event)
payment = create(:payment, type: "CashPayment", amount_cents: event.cost_cents, amount_cents_remaining: nil)
create(:allocation, source: payment, allocatable: registration, amount: event.cost_cents)
@@ -314,7 +319,7 @@
it "locks the W-9 (no link) until a payment is on file" do
w9 = create(:resource, title: "W-9", kind: "Form")
- DefaultTicketCallouts.seed(event)
+ BuiltinCallouts.seed(event)
get registration_payment_path(registration.slug)
expect(response.body).not_to include(registration_resource_path(registration.slug, w9, return_to: "payment"))
@@ -328,8 +333,8 @@
it "drops the W-9 when the payment callout's resource is removed" do
w9 = create(:resource, title: "W-9", kind: "Form")
- DefaultTicketCallouts.seed(event)
- event.registration_ticket_callouts.find_by(magic_key: "payment").resources.destroy_all
+ BuiltinCallouts.seed(event)
+ event.registration_ticket_callouts.find_by(builtin_key: "payment").resources.destroy_all
get registration_payment_path(registration.slug)
expect(response.body).not_to include(registration_resource_path(registration.slug, w9, return_to: "payment"))
end
@@ -504,18 +509,19 @@
end
describe "GET /registration/:slug/handouts" do
- let(:event) { create(:event, facilitator_training: true) }
let!(:registration) { create(:event_registration, event: event, registrant: user.person) }
it "links each handout to its registrant resource page, returning to handouts" do
handout = create(:resource, title: "Aha Moments", kind: "Handout")
- DefaultTicketCallouts.seed(event) # materialize the handouts callout, linking the resource
+ BuiltinCallouts.seed(event) # materialize the handouts callout, linking the resource
+ event.registration_ticket_callouts.find_by(builtin_key: "handouts").update!(hidden: false)
get registration_handouts_path(registration.slug)
expect(response).to have_http_status(:success)
expect(response.body).to include(registration_resource_path(registration.slug, handout, return_to: "handouts"))
end
it "shows a placeholder when no handouts are present" do
+ create(:registration_ticket_callout, event:, builtin_key: "handouts", hidden: false)
get registration_handouts_path(registration.slug)
expect(response.body).to include("Training handouts will be available here soon.")
end
diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb
index 7c6a2e6b5..bf08395bd 100644
--- a/spec/requests/events_spec.rb
+++ b/spec/requests/events_spec.rb
@@ -148,7 +148,7 @@ def offer_ce!(target_event)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Sample ticket preview")
expect(response.body).to include("Sample Registrant")
- # Magic callout cards render (the sample event is paid, so the payment
+ # Built-in callout cards render (the sample event is paid, so the payment
# card shows) without raising on the unsaved sample's sentinel slug.
expect(response.body).to include("Make your payment")
end
@@ -246,6 +246,15 @@ def offer_ce!(target_event)
get new_event_path
expect(response).to have_http_status(:ok)
end
+
+ it "renders the built-in callouts as editable rows before the first save" do
+ sign_in admin
+ get new_event_path
+ # Built-in rows are built in memory, so their title is editable and the
+ # builtin_key round-trips as a hidden field.
+ expect(response.body).to include("Frequently asked questions")
+ expect(response.body).to include('name="event[registration_ticket_callouts_attributes][0][builtin_key]"')
+ end
end
context "as non-admin" do
@@ -323,6 +332,24 @@ def offer_ce!(target_event)
expect(response).to redirect_to(event_url(Event.last))
end
+ it "persists edited built-in callouts from the new form without duplicating them" do
+ params = valid_params.deep_dup
+ params[:event][:registration_ticket_callouts_attributes] = {
+ "0" => { builtin_key: "payment", title: "Pay your balance", subtitle: "view balance", callout_type: "action", color_class: "orange", published: "1" },
+ "1" => { builtin_key: "faq", title: "Frequently asked questions", callout_type: "reference", color_class: "blue", published: "0" }
+ }
+
+ expect { post events_path, params: params }.to change(Event, :count).by(1)
+
+ created = Event.order(created_at: :desc).first
+ # The two submitted built-ins persist their edits, and the post-save seed
+ # fills the remaining six — every built-in key present exactly once.
+ expect(created.registration_ticket_callouts.builtin.pluck(:builtin_key).sort).to eq(RegistrationTicketCallout::BUILTIN_KEYS.sort)
+ payment = created.registration_ticket_callouts.find_by(builtin_key: "payment")
+ expect(payment.title).to eq("Pay your balance")
+ expect(payment.published?).to be true
+ end
+
it "stores start_date/end_date in UTC when created by user in Pacific time zone" do
admin_pt = create(:user, :admin, time_zone: "Pacific Time (US & Canada)")
sign_in admin_pt
diff --git a/spec/services/magic_ticket_callouts_spec.rb b/spec/services/builtin_callout_cards_spec.rb
similarity index 90%
rename from spec/services/magic_ticket_callouts_spec.rb
rename to spec/services/builtin_callout_cards_spec.rb
index ee24ef2ce..8a7ff97ae 100644
--- a/spec/services/magic_ticket_callouts_spec.rb
+++ b/spec/services/builtin_callout_cards_spec.rb
@@ -1,6 +1,6 @@
require "rails_helper"
-RSpec.describe MagicTicketCallouts do
+RSpec.describe BuiltinCalloutCards do
let(:event) { create(:event) }
let(:registration) { create(:event_registration, event:) }
@@ -22,21 +22,19 @@ def card(reg, title)
expect(card_titles(registration)).not_to include("Payment")
end
- it "shows Handouts and FAQ only for facilitator trainings" do
- expect(card_titles(registration)).not_to include("Handouts", "Frequently asked questions")
-
+ it "never surfaces the row-driven Handouts or FAQ cards in the code fallback" do
+ # These are admin-published now — there's no code fallback for them, even on
+ # a facilitator training.
event.update!(facilitator_training: true)
- expect(card_titles(registration)).to include("Handouts", "Frequently asked questions")
+ expect(card_titles(registration)).not_to include("Handouts", "Frequently asked questions")
end
it "skips a built-in card the event has materialized (it renders from the row instead)" do
- event.update!(facilitator_training: true)
- create(:registration_ticket_callout, event:, magic_key: "faq", title: "Frequently asked questions")
+ event.update!(event_details: "Bring supplies")
+ create(:registration_ticket_callout, event:, builtin_key: "event_details", title: "Before you attend")
- # No duplicate FAQ from the code path; Handouts (not materialized) still renders here.
- titles = card_titles(registration)
- expect(titles.count("Frequently asked questions")).to eq(0)
- expect(titles).to include("Handouts")
+ # Materialized, so the code fallback omits it — the row renders it instead.
+ expect(card_titles(registration)).not_to include(event.event_details_label)
end
it "makes the payment card an action card while a balance is due, reference once paid" do
@@ -169,7 +167,8 @@ def card(reg, title)
expect(scholarship_card.badge_classes).to include("fuchsia")
end
- it "places payment first and FAQ last in the full ordering" do
+ it "orders the code-fallback cards from payment downward" do
+ # Handouts/FAQ are row-driven, so they never appear in this fallback.
event.update!(facilitator_training: true, event_details: "Bring supplies",
ce_hours_details: "6 hours", ce_hours_offered: 6,
videoconference_url: "https://example.zoom.us/j/123",
@@ -183,9 +182,7 @@ def card(reg, title)
"Scholarship",
event.ce_hours_details_label,
event.event_details_label,
- "Videoconference",
- "Handouts",
- "Frequently asked questions"
+ "Videoconference"
])
end
end
@@ -194,7 +191,7 @@ def card(reg, title)
it "uses the row's editable presentation (title/subtitle/colour) but the app's live badge/link" do
# Videoconference isn't app-coloured, so the row's colour is honoured.
event.update!(videoconference_url: "https://example.com/z")
- callout = create(:registration_ticket_callout, event:, magic_key: "videoconference",
+ callout = create(:registration_ticket_callout, event:, builtin_key: "videoconference",
title: "Your documents", subtitle: "Downloads", color_class: "green")
card = described_class.new(registration).card_for(callout)
@@ -205,7 +202,7 @@ def card(reg, title)
it "keeps Payment's live-status colour, overriding the selected colour" do
event.update!(cost_cents: 5_000)
- callout = create(:registration_ticket_callout, event:, magic_key: "payment",
+ callout = create(:registration_ticket_callout, event:, builtin_key: "payment",
title: "Pay your balance", color_class: "green")
card = described_class.new(registration).card_for(callout)
@@ -215,7 +212,7 @@ def card(reg, title)
end
it "returns nil when the card shouldn't show for this registration" do
- callout = create(:registration_ticket_callout, event:, magic_key: "certificate",
+ callout = create(:registration_ticket_callout, event:, builtin_key: "certificate",
title: "Certificate of completion")
# Certificate isn't unlocked (event not ended, not attended).
@@ -226,7 +223,7 @@ def card(reg, title)
event.update!(ce_hours_cost_cents: 15_000, ce_payment_due_deadline: Date.new(2026, 8, 15))
license = create(:professional_license, person: registration.registrant, number: "LIC123")
create(:continuing_education_registration, event_registration: registration, professional_license: license)
- callout = create(:registration_ticket_callout, event:, magic_key: "ce_hours", title: "CE credit")
+ callout = create(:registration_ticket_callout, event:, builtin_key: "ce_hours", title: "CE credit")
card = described_class.new(registration.reload).card_for(callout)
expect(card.title).to eq("CE credit") # row owns the text
diff --git a/spec/services/default_ticket_callouts_spec.rb b/spec/services/builtin_callouts_spec.rb
similarity index 67%
rename from spec/services/default_ticket_callouts_spec.rb
rename to spec/services/builtin_callouts_spec.rb
index dbf04e85f..4730de0ff 100644
--- a/spec/services/default_ticket_callouts_spec.rb
+++ b/spec/services/builtin_callouts_spec.rb
@@ -1,13 +1,50 @@
require "rails_helper"
-RSpec.describe DefaultTicketCallouts do
+RSpec.describe BuiltinCallouts do
+ describe "#build" do
+ it "builds all eight built-ins as unsaved in-memory rows on a new event" do
+ event = Event.new
+
+ built = described_class.build(event)
+
+ expect(built.map(&:builtin_key)).to contain_exactly(
+ "payment", "certificate", "scholarship", "ce_hours", "event_details",
+ "videoconference", "handouts", "faq"
+ )
+ expect(built).to all(be_new_record)
+ expect(event.registration_ticket_callouts).to match_array(built)
+ end
+
+ it "is idempotent — skips keys already present on the association" do
+ event = create(:event)
+ described_class.seed(event)
+
+ built = described_class.build(event)
+
+ expect(built).to be_empty
+ expect(event.registration_ticket_callouts.builtin.count).to eq(8)
+ end
+
+ it "builds a paid event's Payment card with the W-9 link (subtitle) in memory" do
+ w9 = create(:resource, title: "W-9")
+ event = Event.new(cost_cents: 5_000)
+
+ described_class.build(event)
+
+ payment = event.registration_ticket_callouts.find { |c| c.builtin_key == "payment" }
+ link = payment.registration_ticket_callout_resources.find { |r| r.resource == w9 }
+ expect(link).to be_present
+ expect(link.subtitle).to eq("AWBW's W-9 tax form for your records")
+ end
+ end
+
describe "#seed" do
it "materializes all eight built-in callouts for every event" do
event = create(:event, cost_cents: 0) # free, no scholarship form, no VC link
described_class.seed(event)
- keys = event.registration_ticket_callouts.magic.pluck(:magic_key)
+ keys = event.registration_ticket_callouts.builtin.pluck(:builtin_key)
expect(keys).to contain_exactly(
"payment", "certificate", "scholarship", "ce_hours", "event_details",
"videoconference", "handouts", "faq"
@@ -22,7 +59,7 @@
described_class.seed(event)
- expect(event.registration_ticket_callouts.ordered.map(&:magic_key)).to eq(
+ expect(event.registration_ticket_callouts.ordered.map(&:builtin_key)).to eq(
%w[payment certificate scholarship ce_hours event_details videoconference handouts faq]
)
end
@@ -31,49 +68,34 @@
event = create(:event, start_date: Date.new(2026, 9, 10))
described_class.seed(event)
- vc = event.registration_ticket_callouts.find_by(magic_key: "videoconference")
+ vc = event.registration_ticket_callouts.find_by(builtin_key: "videoconference")
expect(vc).to be_present
expect(vc.display_from.to_date).to eq(Date.new(2026, 9, 3))
expect(vc.hidden).to be(true) # non-training: hidden by default
end
- it "hides all callouts by default on a non-training event, shows all on a facilitator training" do
- form = create(:form)
+ it "hides every callout by default, regardless of facilitator training" do
non_training = create(:event, cost_cents: 0)
- non_training.event_forms.create!(form:, role: "scholarship")
training = create(:event, :publicly_visible, facilitator_training: true, cost_cents: 100,
videoconference_url: "https://example.com/vc")
- training.event_forms.create!(form:, role: "scholarship")
described_class.seed(non_training)
described_class.seed(training)
- non_training.registration_ticket_callouts.magic.each do |callout|
- expect(callout.hidden).to be(true), "expected #{callout.magic_key} hidden on non-training"
- end
- training.registration_ticket_callouts.magic.each do |callout|
- expect(callout.hidden).to be(false), "expected #{callout.magic_key} visible on training"
+ [ non_training, training ].each do |event|
+ event.registration_ticket_callouts.builtin.each do |callout|
+ expect(callout.hidden).to be(true), "expected #{callout.builtin_key} hidden by default"
+ end
end
end
- it "defaults Certificate off for a non-training event and on for a facilitator training" do
- non_training = create(:event, facilitator_training: false)
- training = create(:event, facilitator_training: true)
-
- described_class.seed(non_training)
- described_class.seed(training)
-
- expect(non_training.registration_ticket_callouts.find_by(magic_key: "certificate").hidden).to be(true)
- expect(training.registration_ticket_callouts.find_by(magic_key: "certificate").hidden).to be(false)
- end
-
it "links the W-9 to the Payment card as a removable resource on paid events" do
w9 = create(:resource, title: "W-9")
event = create(:event) # paid by factory, so the W-9 seeds
described_class.seed(event)
- payment = event.registration_ticket_callouts.find_by(magic_key: "payment")
+ payment = event.registration_ticket_callouts.find_by(builtin_key: "payment")
expect(payment.resources).to eq([ w9 ])
end
@@ -83,7 +105,7 @@
described_class.seed(event)
- payment = event.registration_ticket_callouts.find_by(magic_key: "payment")
+ payment = event.registration_ticket_callouts.find_by(builtin_key: "payment")
expect(payment).to be_present
expect(payment.resources).to be_empty # no W-9 on a free event
end
@@ -95,8 +117,8 @@
described_class.seed(event)
- ce = event.registration_ticket_callouts.find_by(magic_key: "ce_hours")
- details = event.registration_ticket_callouts.find_by(magic_key: "event_details")
+ ce = event.registration_ticket_callouts.find_by(builtin_key: "ce_hours")
+ details = event.registration_ticket_callouts.find_by(builtin_key: "event_details")
expect(ce.title).to eq("Continuing education")
expect(ce.description).to eq("
CAMFT approved.
")
expect(details.title).to eq("Art supplies")
@@ -108,7 +130,7 @@
it "reports whether a materialized callout has been customized" do
event = create(:event, facilitator_training: true)
described_class.seed(event)
- faq = event.registration_ticket_callouts.find_by(magic_key: "faq")
+ faq = event.registration_ticket_callouts.find_by(builtin_key: "faq")
expect(described_class.customized?(faq)).to be(false)
@@ -122,7 +144,7 @@
it "treats a changed drip display date as customized" do
event = create(:event, facilitator_training: true)
described_class.seed(event)
- faq = event.registration_ticket_callouts.find_by(magic_key: "faq")
+ faq = event.registration_ticket_callouts.find_by(builtin_key: "faq")
expect(described_class.customized?(faq)).to be(false)
@@ -138,7 +160,7 @@
described_class.seed(event)
- faq = event.registration_ticket_callouts.find_by(magic_key: "faq")
+ faq = event.registration_ticket_callouts.find_by(builtin_key: "faq")
expect(faq.description).to include("Who is this training designed for?")
expect(faq.callout_type).to eq("reference")
end
@@ -150,7 +172,7 @@
described_class.seed(event)
- handouts = event.registration_ticket_callouts.find_by(magic_key: "handouts")
+ handouts = event.registration_ticket_callouts.find_by(builtin_key: "handouts")
expect(handouts.resources).to eq([ first, second ])
end
@@ -161,9 +183,9 @@
described_class.seed(event)
- handouts = event.registration_ticket_callouts.find_by(magic_key: "handouts")
+ handouts = event.registration_ticket_callouts.find_by(builtin_key: "handouts")
link = handouts.registration_ticket_callout_resources.joins(:resource).find_by(resources: { title: })
- defaults = DefaultTicketCallouts::HANDOUT_LINK_DEFAULTS[title]
+ defaults = BuiltinCallouts::HANDOUT_LINK_DEFAULTS[title]
expect(link.subtitle).to eq(defaults[:subtitle])
expect(link.page_content).to eq(defaults[:page_content])
end
@@ -172,7 +194,7 @@
create(:resource, title: "Aha Moments")
event = create(:event)
described_class.seed(event)
- handouts = event.registration_ticket_callouts.find_by(magic_key: "handouts")
+ handouts = event.registration_ticket_callouts.find_by(builtin_key: "handouts")
expect(described_class.customized?(handouts)).to be(false)
handouts.registration_ticket_callout_resources.first.update!(subtitle: "Edited")
@@ -182,26 +204,18 @@
expect(described_class.customized?(handouts.reload)).to be(false)
end
- it "shows Handouts and FAQ by default on a facilitator training" do
+ it "hides Handouts and FAQ by default (no config signal auto-publishes them)" do
event = create(:event, facilitator_training: true)
described_class.seed(event)
- expect(event.registration_ticket_callouts.where(magic_key: %w[handouts faq]).pluck(:hidden)).to all(be(false))
- end
-
- it "hides Handouts and FAQ by default on a non-training event" do
- event = create(:event, facilitator_training: false)
-
- described_class.seed(event)
-
- expect(event.registration_ticket_callouts.where(magic_key: %w[handouts faq]).pluck(:hidden)).to all(be(true))
+ expect(event.registration_ticket_callouts.where(builtin_key: %w[handouts faq]).pluck(:hidden)).to all(be(true))
end
- it "is idempotent and never clobbers an existing magic callout" do
+ it "is idempotent and never clobbers an existing built-in callout" do
event = create(:event, facilitator_training: true)
described_class.seed(event)
- faq = event.registration_ticket_callouts.find_by(magic_key: "faq")
+ faq = event.registration_ticket_callouts.find_by(builtin_key: "faq")
faq.update!(description: "
Custom answer.
", hidden: true)
expect { described_class.seed(event) }.not_to change { event.registration_ticket_callouts.count }
@@ -209,40 +223,40 @@
expect(faq.hidden).to be(true)
end
- it "appends the magic callouts after existing custom ones" do
+ it "appends the built-in callouts after existing custom ones" do
event = create(:event)
custom = create(:registration_ticket_callout, event:, title: "Parking")
described_class.seed(event)
expect(event.registration_ticket_callouts.ordered.first).to eq(custom)
- expect(event.registration_ticket_callouts.ordered.map(&:magic_key).compact).to eq(
+ expect(event.registration_ticket_callouts.ordered.map(&:builtin_key).compact).to eq(
%w[payment certificate scholarship ce_hours event_details videoconference handouts faq]
)
end
end
describe ".reset" do
- it "restores an edited magic callout's content, resources, and visibility to default" do
+ it "restores an edited built-in callout's content, resources, and visibility to default" do
resource = create(:resource, title: "AWBW Training Workshop Worksheets")
event = create(:event, facilitator_training: true)
described_class.seed(event)
- faq = event.registration_ticket_callouts.find_by(magic_key: "faq")
- faq.update!(title: "Custom", description: "