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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 `<details><summary>…</summary>…</details>` disclosure (the markup any HTML generator/LLM produces; `<toggle>` and a `title` attribute are accepted aliases; `<details open>` starts expanded) rebuilt into a styled collapsible card. `<details>`/`<summary>` are also on the `form_label_html` allowlist (`FORM_LABEL_TAGS`, plus the `open` attribute), so a disclosure is never stripped on save β€” the parser only upgrades its styling. Rendered through the shared `app/views/events/callouts/_rich_content.html.erb` partial (which wraps each disclosure in `_toggle.html.erb`), used by the 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 `<details>`), 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 `<details><summary>…</summary>…</details>` disclosure (the markup any HTML generator/LLM produces; `<toggle>` and a `title` attribute are accepted aliases; `<details open>` starts expanded) rebuilt into a styled collapsible card. `<details>`/`<summary>` are also on the `form_label_html` allowlist (`FORM_LABEL_TAGS`, plus the `open` attribute), so a disclosure is never stripped on save β€” the parser only upgrades its styling. Rendered through the shared `app/views/events/callouts/_rich_content.html.erb` partial (which wraps each disclosure in `_toggle.html.erb`), used by the 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 `<details>`), falling back to `BuiltinCallouts.faq_html` when the card isn't materialized. Content with no disclosure renders unchanged

### Affiliations

Expand Down
34 changes: 21 additions & 13 deletions app/controllers/events/callouts_controller.rb
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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:) }
Expand All @@ -291,15 +299,15 @@ 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

# A greyed-out, non-navigating card (no href) for a document that isn't
# 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

Expand Down
13 changes: 9 additions & 4 deletions app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading