diff --git a/AGENTS.md b/AGENTS.md index cb42e92f1..76f6cb9d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) -- `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 +- `BuiltinCalloutCards` — Renders the live, per-registration ticket callout cards (payment, certificate, scholarship, CE hours, videoconference), overlaying dynamic status (badge, colour, visibility guard, destination) on each materialized built-in row via `#card_for`. Rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `BuiltinCallouts`) so the two paths never double-render, and `#cards` serves as the fallback for events not yet seeded; `.editor_cards` builds the editor's preview cards. Art supplies, Handouts, and FAQ are pure content cards with no builder here — they render from their row. Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized) +- `BuiltinCallouts` — Owns the built-in callout definitions and materializes them into `RegistrationTicketCallout` rows in canonical ticket order: `seed` persists (on create, and lazily on edit so older events heal with no backfill), `build` makes the same rows in memory for the new-event form (with `builtin_key` round-tripped through nested attributes), `reset`/`customized?` back the "Restore default" control. All eight seed **hidden** by default — admins publish the ones they want; there's no config-based auto-publish. Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Art supplies, Handouts, FAQ) render their own copy/resources on the generic callout page; "behavioral" cards render live status through `BuiltinCalloutCards#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Videoconference drips a week before start via `display_from`. CE hours and Art supplies are edited like every other built-in — their title/text live entirely on the row (the legacy `event_details*`/`ce_hours_details*` event columns were dropped); the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE page reads the row's title/description. Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`) +- `CalloutContent` — Parses admin-authored callout HTML into ordered segments so **every** callout content page renders the same way: plain rich text, with each standard `
` 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 art-supplies ("Art supplies & what to bring", a content callout on the generic page), CE hours, custom-callout, behavioural-card-intro, and FAQ pages. The FAQ page renders the editable `faq` callout `description` (each question a `
`), 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 51fcd776b..e421c06d9 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -77,8 +77,10 @@ def sign_agreement end end - # CE hours status: hours, amount owed, and license number. + # CE hours status: hours, amount owed, and license number. The heading and the + # requirements copy live on the materialized ce_hours callout row now. def ce + @ce_callout = @event.registration_ticket_callouts.find_by(builtin_key: "ce_hours") case params[:checkout] when "success" flash.now[:notice] = "Your CE payment was successful." diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 8ce7e6109..1424cf656 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -1,8 +1,8 @@ class EventsController < ApplicationController include AhoyTracking, TagAssignable - skip_before_action :authenticate_user!, only: [ :index, :show, :staff, :details, :ce_hours ] + skip_before_action :authenticate_user!, only: [ :index, :show, :staff ] skip_before_action :verify_authenticity_token, only: [ :preview ] - before_action :set_event, only: %i[ show edit update destroy preview dashboard sample_ticket background registrants onboarding details ce_hours staff edit_staff update_staff recipients bulk_payments preview_reminder confirm_reminder send_reminder copy_registration_form allocate_bulk_payment create_bulk_payment ] + before_action :set_event, only: %i[ show edit update destroy preview dashboard sample_ticket background registrants onboarding staff edit_staff update_staff recipients bulk_payments preview_reminder confirm_reminder send_reminder copy_registration_form allocate_bulk_payment create_bulk_payment ] def index authorize! @@ -185,37 +185,6 @@ def onboarding end end - # Public "Before you attend" page (materials, supplies, policies). Linked from - # the registration ticket. When no details are set there is nothing to show, so - # fall back to the event page. - def details - authorize! @event, to: :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 - - if @event_details_body.blank? - redirect_to event_path(@event, reg: params[:reg].presence) - return - end - - @event = @event.decorate - end - - # Public CE hours page (continuing education requirements, payment, sign-in - # rules). Linked from the registration ticket. When no details are set there - # is nothing to show, so fall back to the event page. - def ce_hours - authorize! @event, to: :ce_hours? - - unless @event.ce_eligible? - redirect_to event_path(@event, reg: params[:reg].presence) - return - end - - @event = @event.decorate - end def staff authorize! @event, to: :staff? diff --git a/app/models/event.rb b/app/models/event.rb index b1b9e0147..336bcb205 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -210,17 +210,11 @@ def name title end - # Heading shown on the ticket call-out and the details page. Falls back to the - # default even when an admin clears it, so the section never renders unlabelled. - def event_details_label - super.presence || "Before you attend" - end - - # Heading shown on the CE hours ticket call-out and its details page. Falls - # back to the default even when an admin clears it, so the section never - # renders unlabelled. - def ce_hours_details_label - super.presence || "CE hours" + # The CE hours callout's admin-edited heading, read from its materialized row's + # title (that's where the label lives now the ce_hours_details_label column is + # gone). Falls back to the default so the CE card/mailer never render unlabelled. + def ce_hours_label + registration_ticket_callouts.find_by(builtin_key: "ce_hours")&.title.presence || "CE hours" end # Virtual attributes for date/time inputs (Firefox datetime-local compat) diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index 017816496..de6ee9d65 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -11,15 +11,15 @@ class RegistrationTicketCallout < ApplicationRecord # 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. BUILTIN_KEYS = %w[ - payment certificate scholarship ce_hours event_details + payment certificate scholarship ce_hours art_supplies videoconference handouts faq ].freeze # "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_BUILTIN_KEYS = %w[ handouts faq ].freeze + # callouts), on the generic callout page. "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_BUILTIN_KEYS = %w[ art_supplies 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. diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index a081a54f7..303d55fec 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -123,10 +123,6 @@ def google_analytics? :short_description, :rhino_header, :rhino_description, - :event_details, - :event_details_label, - :ce_hours_details, - :ce_hours_details_label, :ce_hours_offered, :ce_hours_cost, :ce_hours_request_deadline, @@ -171,8 +167,6 @@ def google_analytics? end alias_rule :preview?, to: :edit? - alias_rule :details?, to: :show? - alias_rule :ce_hours?, to: :show? private diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index 284ba9f32..83c155b2c 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -31,16 +31,15 @@ def theme = DomainTheme.swatch(color) end # 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). `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, :builtin_key, :icon_class, :color, :title, :subtitle, :visibility, :note) do + # callouts section — a greyed-out preview of a card the app controls, so the full + # ticket context is visible. `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(:builtin_key, :icon_class, :color, :title, :subtitle, :visibility, :note) do def theme = DomainTheme.swatch(color) - def editable? = key.present? end # Every built-in card in the order it appears on a ticket, for the editor to @@ -52,25 +51,24 @@ def self.editor_cards(event) # 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), - EditorCard.new(nil, "scholarship", "fa-solid fa-award", "fuchsia", "Scholarship", "Your scholarship request and award", "When the registrant requested a scholarship", nil), - 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.builtin_key) } + EditorCard.new("payment", "fa-solid fa-credit-card", "orange", "Payment", "Your balance and payment history", "When the event has a cost", nil), + EditorCard.new("certificate", "fa-solid fa-certificate", "green", "Certificate of completion", "View and download your certificate", "Once the certificate is unlocked", nil), + EditorCard.new("scholarship", "fa-solid fa-award", "fuchsia", "Scholarship", "Your scholarship request and award", "When the registrant requested a scholarship", nil), + EditorCard.new("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("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("faq", "fa-solid fa-circle-question", "blue", "Frequently asked questions", "Common questions about the 2-day training", "On facilitator trainings", nil) + ].reject { |card| materialized.include?(card.builtin_key) } end # 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. + # Content callouts (art supplies, handouts, FAQ) 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 }.freeze @@ -94,9 +92,14 @@ def self.config_gap(event, builtin_key) end end - def initialize(event_registration) + def initialize(event_registration, preview: false) @registration = event_registration @event = event_registration.event + # The admin sample-ticket preview illustrates a hypothetical registrant with + # options toggled on, so the scholarship / CE cards render from the sample + # registration's options even when the concrete event isn't configured for them + # (their config_gap is skipped). On a real ticket this stays false. + @preview = preview end # The visible built-in cards for this registration, in default order. Cards the @@ -104,7 +107,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 { |builtin_key, _| materialized?(builtin_key) || skip_in_fallback?(builtin_key) } + CARD_BUILDERS.reject { |builtin_key, _| materialized?(builtin_key) } .filter_map { |_, builder| send(builder) } end @@ -117,8 +120,6 @@ def card_for(callout) 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.builtin_key == "event_details" && callout.description.blank? base.with( title: callout.title, @@ -141,12 +142,6 @@ def materialized?(builtin_key) @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?(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 # in full. Its page lists every allocation with the running balance, plus the # linked documents (the W-9, and the invoice/receipt) for paid events. @@ -178,7 +173,7 @@ def certificate_card # pending shows an amber "$X · Tasks outstanding" badge (action needed); fully # met shows a fuchsia amount badge. def scholarship_status_card - return if self.class.config_gap(event, "scholarship") + return if !@preview && self.class.config_gap(event, "scholarship") return unless registration.scholarship_requested? # Awarded is display-only: the scholarship record exists earlier, but the award # is only shown as awarded once the recipient signs the agreement. Until then @@ -215,14 +210,14 @@ def scholarship_badge(awarded, tasks_outstanding) # they have, becoming a reference card once requested with hours and a license # number on file. Shown when the event offers CE or the registrant asked for it. def ce_hours_card - return if self.class.config_gap(event, "ce_hours") + return if !@preview && self.class.config_gap(event, "ce_hours") return unless registration.ce_registered? complete = registration.ce_license_provided? # An outstanding CE balance turns the card orange (an action card), matching # the payment card, rather than the resting teal. due = registration.continuing_education_registrations.first&.remaining_cost.to_i.positive? Card.new(icon_class: "fa-solid fa-graduation-cap", color: due ? "orange" : "teal", - title: event.ce_hours_details_label, + title: event.ce_hours_label, subtitle: ce_hours_subtitle, href: registration_ce_path(registration.slug), target: nil, trailing_icon: "fa-solid fa-arrow-right", @@ -275,15 +270,6 @@ def ce_deadline_text(deadline) deadline.strftime("%b %-d") end - # "Art supplies & what to bring" — the event's own details page. - def event_details_card - Card.new(icon_class: "fa-solid fa-palette", color: "blue", - title: event.event_details_label, - subtitle: "Important info for this event — please read", - href: details_event_path(event, reg: registration.slug), - target: nil, trailing_icon: "fa-solid fa-arrow-right") - end - # Shown only when the event has a videoconference URL set. def videoconference_card return if self.class.config_gap(event, "videoconference") diff --git a/app/services/builtin_callouts.rb b/app/services/builtin_callouts.rb index eb7dab097..b3046a21a 100644 --- a/app/services/builtin_callouts.rb +++ b/app/services/builtin_callouts.rb @@ -227,11 +227,10 @@ def definitions }, { 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. - title: ->(event) { event.ce_hours_details_label }, - description: ->(event) { event.ce_hours_details }, + # Title/text live on the row like every other built-in; admins fill in the + # CE requirements copy per event. The row also carries the CE + # hours-offered/cost config. + title: "CE hours", subtitle: "Continuing education — requirements & how to request", callout_type: "action", icon_class: "fa-solid fa-graduation-cap", @@ -239,11 +238,10 @@ def definitions hidden: ->(_event) { true } }, { - 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 }, - description: ->(event) { event.event_details }, + builtin_key: "art_supplies", + # A content callout: renders its own editable copy on the generic callout + # page. Admins add the "what to bring" details per event. + title: "Art supplies & what to bring", subtitle: "Important info for this event — please read", callout_type: "reference", icon_class: "fa-solid fa-palette", diff --git a/app/views/event_mailer/_ce_deadlines.html.erb b/app/views/event_mailer/_ce_deadlines.html.erb index 015e52364..55e4a2a59 100644 --- a/app/views/event_mailer/_ce_deadlines.html.erb +++ b/app/views/event_mailer/_ce_deadlines.html.erb @@ -6,7 +6,7 @@ <% if event.ce_eligible? && (event.ce_hours_request_deadline || event.ce_payment_due_deadline) %>

- <%= event.ce_hours_details_label %> + <%= event.ce_hours_label %>

<% if event.ce_hours_request_deadline %>

diff --git a/app/views/event_mailer/_ce_deadlines.text.erb b/app/views/event_mailer/_ce_deadlines.text.erb index 755ff4fc2..5aaa7ed84 100644 --- a/app/views/event_mailer/_ce_deadlines.text.erb +++ b/app/views/event_mailer/_ce_deadlines.text.erb @@ -1,5 +1,5 @@ <% if event.ce_eligible? && (event.ce_hours_request_deadline || event.ce_payment_due_deadline) %> -<%= event.ce_hours_details_label %> +<%= event.ce_hours_label %> <% if event.ce_hours_request_deadline %>Request CE credit by <%= event.ce_hours_request_deadline.strftime("%B %-d, %Y") %> <% end %><% if event.ce_payment_due_deadline %>CE payment due by <%= event.ce_payment_due_deadline.strftime("%B %-d, %Y") %> <% end %><% end %> \ No newline at end of file diff --git a/app/views/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb index 4d351e6c8..a4cbd948e 100644 --- a/app/views/event_registrations/_ticket.html.erb +++ b/app/views/event_registrations/_ticket.html.erb @@ -100,7 +100,7 @@

<% end %> - <% builtin_cards = BuiltinCalloutCards.new(event_registration) %> + <% builtin_cards = BuiltinCalloutCards.new(event_registration, preview: preview) %>