From 8afc00bebb32836a22ef791fa3930a1fe08208ad Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 15 Jul 2026 01:25:26 -0400 Subject: [PATCH 1/4] Move FAQ + scholarship callout pages onto the shared _callout_page layout Removes the duplicated back-eyebrow + branded-header chrome both pages hand-rolled. Scholarship now renders through the shared layout, so it picks up @builtin_intro (the materialized row's 'Callout page text'), which the old bespoke markup silently dropped. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/views/events/callouts/faq.html.erb | 38 +--- .../events/callouts/scholarship.html.erb | 195 ++++++++---------- 2 files changed, 91 insertions(+), 142 deletions(-) diff --git a/app/views/events/callouts/faq.html.erb b/app/views/events/callouts/faq.html.erb index edfb74654..1f6a96fae 100644 --- a/app/views/events/callouts/faq.html.erb +++ b/app/views/events/callouts/faq.html.erb @@ -1,36 +1,12 @@ <% content_for(:page_bg_class, "public") %> <% content_for(:page_title, "Frequently asked questions — #{@event.title}") %> -
- <%= link_to registration_ticket_path(@event_registration.slug), - class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %> - Back to ticket - <% end %> -
+<%= render layout: "events/callouts/callout_page", locals: { title: "Frequently asked questions" } do %> +

2-day training — general questions

+ <%= render "events/callouts/rich_content", content: @faq_content %> -
-
-
-
-
- <%= image_tag("logo.png", alt: "Organization logo", class: "h-9 w-auto") %> -
-
-

Frequently asked questions

-

<%= @event.title %>

-
-
-
-
- -
-

2-day training — general questions

- <%= render "events/callouts/rich_content", content: @faq_content %> - -
-

Still have questions?

-

You'll receive more details as we get closer to the training dates. In the meantime, please <%= link_to "reach out", contact_us_path, class: "text-blue-700 underline hover:text-blue-900" %> — we're always happy to help. We look forward to creating and connecting with you!

-
-
+
+

Still have questions?

+

You'll receive more details as we get closer to the training dates. In the meantime, please <%= link_to "reach out", contact_us_path, class: "text-blue-700 underline hover:text-blue-900" %> — we're always happy to help. We look forward to creating and connecting with you!

-
+<% end %> diff --git a/app/views/events/callouts/scholarship.html.erb b/app/views/events/callouts/scholarship.html.erb index 94e93caa8..19c70c1e3 100644 --- a/app/views/events/callouts/scholarship.html.erb +++ b/app/views/events/callouts/scholarship.html.erb @@ -5,128 +5,101 @@ eyebrow back there instead of the registrant's ticket. Gated on edit access so a registrant who lands on this URL still gets the default ticket back. %> <% from_scholarship_edit = params[:return_to] == "scholarship" && @scholarship && allowed_to?(:edit?, @scholarship) %> -
- <% if from_scholarship_edit %> - <%= link_to edit_scholarship_path(@scholarship), - class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %> - Back to scholarship - <% end %> - <% else %> - <%= link_to registration_ticket_path(@event_registration.slug), - class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %> - Back to ticket - <% end %> - <% end %> -
+<% eyebrow = from_scholarship_edit ? { back_path: edit_scholarship_path(@scholarship), back_label: "Back to scholarship" } : {} %> -
-
-
-
-
- <%= image_tag("logo.png", alt: "Organization logo", class: "h-9 w-auto") %> -
-
-

Scholarship

-

<%= @event.title %>

-
+<%= render layout: "events/callouts/callout_page", locals: { title: "Scholarship", **eyebrow } do %> +
+ <% if @scholarship && allowed_to?(:edit?, @scholarship) %> + <%# Admin-only jump to the management surface for this scholarship. Hidden from + registrants; opens in a new tab so the registrant view is kept. %> +
+ <%= link_to edit_scholarship_path(@scholarship), target: "_blank", rel: "noopener", + class: "inline-flex items-center gap-1 text-xs rounded-full border px-2 py-0.5 bg-sky-100 text-sky-700 border-sky-200 hover:bg-sky-200 transition" do %> + + Edit scholarship + + <% end %>
-
-
+ <% end %> -
- <% if @scholarship && allowed_to?(:edit?, @scholarship) %> - <%# Admin-only jump to the management surface for this scholarship. Hidden from - registrants; opens in a new tab so the registrant view is kept. %> -
- <%= link_to edit_scholarship_path(@scholarship), target: "_blank", rel: "noopener", - class: "inline-flex items-center gap-1 text-xs rounded-full border px-2 py-0.5 bg-sky-100 text-sky-700 border-sky-200 hover:bg-sky-200 transition" do %> - - Edit scholarship - - <% end %> + <% if @scholarship %> + <% grant = @scholarship.grant %> +
+
+ <%# Shown as an offer until the recipient signs the agreement, then as + the confirmed award. The amount/allocation is unchanged either way. %> +

<%= @scholarship.agreement_signed? ? "Amount awarded" : "Amount offered" %>

+

<%= dollars_from_cents(@scholarship.amount_cents) %>

- <% end %> + <%# Status chip: pending until the agreement is signed, then the tasks state. %> + <% if !@scholarship.agreement_signed? %> + + Pending agreement + + <% elsif @scholarship.tasks_completed? %> + + Tasks completed + + <% else %> + + Tasks outstanding + + <% end %> +
- <% if @scholarship %> - <% grant = @scholarship.grant %> -
-
- <%# Shown as an offer until the recipient signs the agreement, then as - the confirmed award. The amount/allocation is unchanged either way. %> -

<%= @scholarship.agreement_signed? ? "Amount awarded" : "Amount offered" %>

-

<%= dollars_from_cents(@scholarship.amount_cents) %>

-
- <%# Status chip: pending until the agreement is signed, then the tasks state. %> - <% if !@scholarship.agreement_signed? %> - - Pending agreement - - <% elsif @scholarship.tasks_completed? %> - - Tasks completed - - <% else %> - - Tasks outstanding - + <%# Scholarship agreement — shown above the details so accepting comes first. + A single Agree button signs it; afterwards we confirm with the date. %> +
+ <% if @scholarship.agreement_signed? %> +

+ + Agreement signed<% if @scholarship.agreement_signed_at %> · <%= @scholarship.agreement_signed_at.strftime("%B %-d, %Y") %><% end %> +

+ <% else %> +

Agree to complete your scholarship tasks to accept this award.

+ <%= form_with url: registration_scholarship_agreement_path(@event_registration.slug), method: :post, class: "mt-3" do %> + <% end %> -
+ <% end %> +
- <%# Scholarship agreement — shown above the details so accepting comes first. - A single Agree button signs it; afterwards we confirm with the date. %> + <% if grant %>
- <% if @scholarship.agreement_signed? %> -

- - Agreement signed<% if @scholarship.agreement_signed_at %> · <%= @scholarship.agreement_signed_at.strftime("%B %-d, %Y") %><% end %> -

- <% else %> -

Agree to complete your scholarship tasks to accept this award.

- <%= form_with url: registration_scholarship_agreement_path(@event_registration.slug), method: :post, class: "mt-3" do %> - - <% end %> - <% end %> -
- - <% if grant %> -
-

Funder

-

<%= grant.funder_name %>

-

<%= grant.name %>

-
- -
- <%= render "scholarships/grant_criteria_tasks", grant: %> -
- <% end %> - <% else %> -
- - - -
-

Scholarship requested

-

Your scholarship request is under review. We'll be in touch about next steps — check back here for your award details.

-
+

Funder

+

<%= grant.funder_name %>

+

<%= grant.name %>

- <% end %> - <%# Jump to the registrant's full submission, where their scholarship - application answers are surfaced (inline for embedded-section forms, or - in a dedicated "Scholarship application" card for separate forms). %> - <% if @form_responses_available %>
- <%= link_to event_public_registration_path(@event, reg: @event_registration.slug), - class: "inline-flex items-center gap-1.5 text-sm font-medium #{DomainTheme.text_class_for(:scholarships, intensity: 700)} hover:underline" do %> - - Review your form responses - - <% end %> + <%= render "scholarships/grant_criteria_tasks", grant: %>
<% end %> -
+ <% else %> +
+ + + +
+

Scholarship requested

+

Your scholarship request is under review. We'll be in touch about next steps — check back here for your award details.

+
+
+ <% end %> + + <%# Jump to the registrant's full submission, where their scholarship + application answers are surfaced (inline for embedded-section forms, or + in a dedicated "Scholarship application" card for separate forms). %> + <% if @form_responses_available %> +
+ <%= link_to event_public_registration_path(@event, reg: @event_registration.slug), + class: "inline-flex items-center gap-1.5 text-sm font-medium #{DomainTheme.text_class_for(:scholarships, intensity: 700)} hover:underline" do %> + + Review your form responses + + <% end %> +
+ <% end %>
-
+<% end %> From c7daa4f6cd74c6ae231314329dda8d6663909aa2 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 15 Jul 2026 01:47:24 -0400 Subject: [PATCH 2/4] Consolidate CE + art supplies onto callout rows; drop legacy Event columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes migrating the CE and art-supplies built-ins fully onto their materialized callout rows, and deletes the two now-orphaned standalone pages. - Rename the event_details built-in to art_supplies and make it a *content* callout, so it renders on the generic callout page (like handouts/FAQ) with no dedicated action/view. Default title 'Art supplies & what to bring'. - Drop the legacy Event columns event_details(_label) and ce_hours_details(_label) (two reversible migrations, no backfill). The heading + copy live on the row now; the CE heading reads from the row via Event#ce_hours_label (used by the CE card, the CE page, and the CE-deadlines mailer). - Delete the orphaned events#ce_hours / events#details pages, routes, controller actions, policy rules, and their specs — superseded by the callout pages. - Remove the dead editor branch, the event_details_card builder, and the now-unused _builtin_callout_card partial. - Seed the built-in callouts on every dev event and move the demo CE / art-supplies copy onto their rows. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/controllers/events/callouts_controller.rb | 4 +- app/controllers/events_controller.rb | 35 +---------- app/models/event.rb | 16 ++--- app/models/registration_ticket_callout.rb | 10 +-- app/policies/event_policy.rb | 6 -- app/services/builtin_callout_cards.rb | 59 ++++++----------- app/services/builtin_callouts.rb | 18 +++--- app/views/event_mailer/_ce_deadlines.html.erb | 2 +- app/views/event_mailer/_ce_deadlines.text.erb | 2 +- .../events/_builtin_callout_card.html.erb | 28 --------- app/views/events/_form.html.erb | 22 ++----- app/views/events/callouts/ce.html.erb | 8 +-- app/views/events/ce_hours.html.erb | 47 -------------- app/views/events/details.html.erb | 31 --------- config/routes.rb | 2 - ...053136_remove_event_details_from_events.rb | 16 +++++ ...137_remove_ce_hours_details_from_events.rb | 16 +++++ db/schema.rb | 6 +- db/seeds/dev/events_management.rb | 42 ++++++++----- .../registration_ticket_callouts_spec.rb | 2 +- spec/requests/events_spec.rb | 63 +------------------ spec/services/builtin_callout_cards_spec.rb | 36 +++++------ spec/services/builtin_callouts_spec.rb | 37 ++++++----- spec/system/event_registration_show_spec.rb | 17 +++-- spec/views/page_bg_class_alignment_spec.rb | 2 - 25 files changed, 169 insertions(+), 358 deletions(-) delete mode 100644 app/views/events/_builtin_callout_card.html.erb delete mode 100644 app/views/events/ce_hours.html.erb delete mode 100644 app/views/events/details.html.erb create mode 100644 db/migrate/20260715053136_remove_event_details_from_events.rb create mode 100644 db/migrate/20260715053137_remove_ce_hours_details_from_events.rb 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..8d6ec694c 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 @@ -104,7 +102,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 +115,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 +137,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. @@ -222,7 +212,7 @@ def ce_hours_card # 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 +265,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/events/_builtin_callout_card.html.erb b/app/views/events/_builtin_callout_card.html.erb deleted file mode 100644 index ef198b877..000000000 --- a/app/views/events/_builtin_callout_card.html.erb +++ /dev/null @@ -1,28 +0,0 @@ -<%# 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 - (BuiltinCalloutCards::EditorCard), label_field/content_field (event attribute - symbols), title_placeholder, content_help, content_placeholder. %> -<% theme = card.theme %> -

- -
-
- Built in - <%= card.visibility %> -
-
-
- <%= f.label label_field, "Title", class: "block text-xs font-medium text-gray-600 mb-0.5" %> - <%= f.text_area label_field, rows: 2, placeholder: title_placeholder, - class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> -
-
- <%= f.label content_field, "Callout page text", class: "block text-xs font-medium text-gray-600 mb-0.5" %> -

<%= content_help %>

- <%= f.text_area content_field, rows: 3, placeholder: content_placeholder, - class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm font-mono" %> -
-
-
-
diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 6b526d84a..c746ae793 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -565,27 +565,14 @@ <%# Built-in cards, shown above custom callouts in the order they appear on - the ticket. The two editable ones (CE hours, art supplies) carry their - ticket colour and editable text; the rest are greyed-out previews of - cards the app controls, so the full ticket context is visible here. + the ticket — greyed-out previews of cards the app controls, so the full + ticket context is visible here. The editable built-ins (CE hours, art + supplies, …) are materialized as real rows and edited in the callout list + below, so they're dropped from this preview. %>
<% BuiltinCalloutCards.editor_cards(@event).each do |card| %> - <% case card.key %> - <% when :ce_hours %> - <%= render "events/builtin_callout_card", f: f, card: card, - label_field: :ce_hours_details_label, content_field: :ce_hours_details, - title_placeholder: "CE hours", - content_help: "CE requirements, payment, sign-in rules, and the post-training evaluation — shown on its own page linked from the ticket. Accepts basic HTML — bold, italics, links, lists, headings, and line breaks. Wrap a part in
Title
to make it a collapsible section.", - content_placeholder: "e.g.

AWBW is approved by CAMFT…

Before the training
  • Email your license number
" %> - <% when :event_details %> - <%= render "events/builtin_callout_card", f: f, card: card, - label_field: :event_details_label, content_field: :event_details, - title_placeholder: "Before you attend", - content_help: "Materials, supplies, what to bring, and policies — shown on its own page linked from the ticket. Wrap a part in
Title
to make it a collapsible section (e.g. one per workshop); anything outside a disclosure stays open. Leave blank to hide it entirely. Accepts basic HTML — bold, italics, links, lists, headings, and line breaks.", - content_placeholder: "e.g.
Workshop 1
  • Clear glass stones
" %> - <% else %>
@@ -631,7 +618,6 @@ <% end %>
- <% end %> <% end %>
diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 4237e5501..9ba558ed8 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -1,5 +1,5 @@ <% content_for(:page_bg_class, "public") %> -<% content_for(:page_title, "#{@event.ce_hours_details_label} — #{@event.title}") %> +<% content_for(:page_title, "#{@event.ce_hours_label} — #{@event.title}") %> <% ce_registration = @event_registration.continuing_education_registrations.first %> <%# @@ -14,7 +14,7 @@ {} end %> -<%= render layout: "events/callouts/callout_page", locals: { title: @event.ce_hours_details_label, **callout_eyebrow } do %> +<%= render layout: "events/callouts/callout_page", locals: { title: @event.ce_hours_label, **callout_eyebrow } do %> <% if @event_registration.ce_registered? %> <% license = ce_registration&.professional_license %> <% license_kind = license&.kind %> @@ -235,11 +235,11 @@ <% end %> <% end %> - <% if @event.ce_hours_details.present? %> + <% if @ce_callout&.description.present? %>

Details

- <%= render "events/callouts/rich_content", content: @event.ce_hours_details %> + <%= render "events/callouts/rich_content", content: @ce_callout.description %>
<% end %> diff --git a/app/views/events/ce_hours.html.erb b/app/views/events/ce_hours.html.erb deleted file mode 100644 index 8dc651515..000000000 --- a/app/views/events/ce_hours.html.erb +++ /dev/null @@ -1,47 +0,0 @@ -<% content_for(:page_bg_class, "public") %> - -<% reg_slug = params[:reg].presence %> -<% back_path = reg_slug ? registration_ticket_path(reg_slug) : event_path(@event) %> -<% back_label = reg_slug ? "Back to ticket" : "Back to event" %> -
- <%= link_to back_path, class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %> - <%= back_label %> - <% end %> -
- -
-
-
-
-
- <%= image_tag("logo.png", alt: "Organization logo", class: "h-9 w-auto") %> -
-
-

<%= @event.ce_hours_details_label %>

-

<%= @event.title %>

-
-
-
-
- -
- <% if @event.ce_hours_request_deadline || @event.ce_payment_due_deadline %> -
- <% 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 %> -
-
Payment due by
-
<%= @event.ce_payment_due_deadline.strftime("%B %-d, %Y") %>
-
- <% end %> -
- <% end %> - <%= render "events/callouts/rich_content", content: @event.ce_hours_details %> -
-
-
diff --git a/app/views/events/details.html.erb b/app/views/events/details.html.erb deleted file mode 100644 index 591cedd39..000000000 --- a/app/views/events/details.html.erb +++ /dev/null @@ -1,31 +0,0 @@ -<% content_for(:page_bg_class, "public") %> - -<% reg_slug = params[:reg].presence %> -<% back_path = reg_slug ? registration_ticket_path(reg_slug) : event_path(@event) %> -<% back_label = reg_slug ? "Back to ticket" : "Back to event" %> -
- <%= link_to back_path, class: "inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 px-2 py-1" do %> - <%= back_label %> - <% end %> -
- -
-
-
-
-
- <%= image_tag("logo.png", alt: "Organization logo", class: "h-9 w-auto") %> -
-
-

<%= @event_details_title %>

-

<%= @event.title %>

-
-
-
-
- -
- <%= render "events/callouts/rich_content", content: @event_details_body %> -
-
-
diff --git a/config/routes.rb b/config/routes.rb index a82fbba53..cf5f39ab2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -138,8 +138,6 @@ get :background get :registrants get :onboarding - get :details - get :ce_hours get :staff get "staff/edit", action: :edit_staff, as: :edit_staff patch "staff", action: :update_staff diff --git a/db/migrate/20260715053136_remove_event_details_from_events.rb b/db/migrate/20260715053136_remove_event_details_from_events.rb new file mode 100644 index 000000000..a97402ffd --- /dev/null +++ b/db/migrate/20260715053136_remove_event_details_from_events.rb @@ -0,0 +1,16 @@ +class RemoveEventDetailsFromEvents < ActiveRecord::Migration[8.0] + # The "Before you attend" / art-supplies copy now lives on the materialized + # art_supplies registration ticket callout row, not on the event. No backfill: + # the built-ins re-seed and dev data re-seeds. + def up + remove_column :events, :event_details, if_exists: true + remove_column :events, :event_details_label, if_exists: true + end + + def down + add_column :events, :event_details, :text unless column_exists?(:events, :event_details) + unless column_exists?(:events, :event_details_label) + add_column :events, :event_details_label, :string, default: "Before you attend", null: false + end + end +end diff --git a/db/migrate/20260715053137_remove_ce_hours_details_from_events.rb b/db/migrate/20260715053137_remove_ce_hours_details_from_events.rb new file mode 100644 index 000000000..90912c2f0 --- /dev/null +++ b/db/migrate/20260715053137_remove_ce_hours_details_from_events.rb @@ -0,0 +1,16 @@ +class RemoveCeHoursDetailsFromEvents < ActiveRecord::Migration[8.0] + # The CE hours heading and requirements copy now live on the materialized + # ce_hours registration ticket callout row (title + description), not on the + # event. No backfill: the built-ins re-seed and dev data re-seeds. + def up + remove_column :events, :ce_hours_details, if_exists: true + remove_column :events, :ce_hours_details_label, if_exists: true + end + + def down + add_column :events, :ce_hours_details, :text unless column_exists?(:events, :ce_hours_details) + unless column_exists?(:events, :ce_hours_details_label) + add_column :events, :ce_hours_details_label, :string, default: "CE hours", null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 39ed76ab0..f4ead408c 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_041845) do +ActiveRecord::Schema[8.1].define(version: 2026_07_15_053137) 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 @@ -534,8 +534,6 @@ t.boolean "autoshow_videoconference_label", default: true, null: false t.boolean "autoshow_videoconference_link", default: true, null: false t.integer "ce_hours_cost_cents" - t.text "ce_hours_details" - t.string "ce_hours_details_label", default: "CE hours", null: false t.decimal "ce_hours_offered", precision: 5, scale: 2 t.date "ce_hours_request_deadline" t.date "ce_payment_due_deadline" @@ -544,8 +542,6 @@ t.integer "created_by_id" t.text "description" t.datetime "end_date", precision: nil - t.text "event_details" - t.string "event_details_label", default: "Before you attend", null: false t.boolean "facilitator_training", default: false, null: false t.boolean "featured", default: false, null: false t.text "ga4_snippet" diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index 81c717f19..f611e0d72 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -292,6 +292,12 @@ ef.form = bulk_payment_form end end + + # Materialize the built-in callouts (payment, CE hours, art supplies, …) as + # editable rows, exactly as creating an event through the admin form does. They + # seed hidden by default; the blocks below fill in and publish the demo copy on + # the trainings. Idempotent, so re-seeding never clobbers. + BuiltinCallouts.seed(event) end # The flagship training runs on Zoom — drive the platform from event settings so @@ -321,8 +327,14 @@ # has a sensible default highlighted event after seeding. admin_user.update!(favorite_event: flagship) if admin_user && flagship -if flagship && flagship.event_details.blank? - flagship.update!(event_details_label: "Art supplies & what to bring", event_details: <<~HTML.strip) +# Fill in and publish the "Art supplies & what to bring" content callout on the +# flagship training so the demo ticket links to a populated page. The row is +# materialized when the event is created; here we just add the copy (its title +# already defaults to "Art supplies & what to bring"). Only when blank so admin +# edits survive a re-seed. +flagship_art_supplies = flagship&.registration_ticket_callouts&.find_by(builtin_key: "art_supplies") +if flagship_art_supplies && flagship_art_supplies.description.blank? + flagship_art_supplies.update!(hidden: false, description: <<~HTML.strip)

Thank you for registering to join us for AWBW's Art Facilitator Training!

Below you'll find information about the art supplies used in each of the five hands-on workshops included in the training, along with optional printable workshop worksheets. We're sharing these materials in advance in case you'd like to gather supplies or print resources ahead of time.

You will receive additional training information as we get closer to the training dates.

@@ -385,13 +397,14 @@ HTML end -# Seed the "CE hours" details — the continuing-education requirements, payment, -# and sign-in rules that used to live in a long CE confirmation email. Shown on its -# own ticket-linked page (and via the prominent indigo call-out on the ticket) for -# registrants who requested CE credit. A custom label demonstrates that the heading -# is admin-editable. Only set when blank so admin edits survive a re-seed. -if flagship && flagship.ce_hours_details.blank? - flagship.update!(ce_hours_details_label: "Continuing education", ce_hours_details: <<~HTML.strip) +# Fill in the "CE hours" details on the flagship training's materialized ce_hours +# callout — the continuing-education requirements, payment, and sign-in rules that +# used to live in a long CE confirmation email. Shown on its own ticket-linked page +# for registrants who requested CE credit. A custom title demonstrates that the +# heading is admin-editable. Only set when blank so admin edits survive a re-seed. +flagship_ce = flagship&.registration_ticket_callouts&.find_by(builtin_key: "ce_hours") +if flagship_ce && flagship_ce.description.blank? + flagship_ce.update!(hidden: false, title: "Continuing education", description: <<~HTML.strip)

This training is approved by the California Association of Marriage and Family Therapists (CAMFT, provider #000000) for 12 CE hours. AWBW is approved to sponsor continuing education for LMFTs, LCSWs, LPCCs, and LEPs.

Before the training

    @@ -412,10 +425,11 @@ HTML end -# The trauma-informed training also offers CE hours, with the default label. +# The trauma-informed training also offers CE hours, with the default title. trauma = Event.find_by(title: "Facilitator Training: Trauma-Informed Art Practices") -if trauma && trauma.ce_hours_details.blank? - trauma.update!(ce_hours_details: <<~HTML.strip) +trauma_ce = trauma&.registration_ticket_callouts&.find_by(builtin_key: "ce_hours") +if trauma_ce && trauma_ce.description.blank? + trauma_ce.update!(hidden: false, description: <<~HTML.strip)

    This training is approved by CAMFT (provider #000000) for 18 CE hours across its three days.

    • Provide your license type and number at registration; a $25 CE processing fee applies.
    • @@ -431,7 +445,7 @@ # subtitle, and a paid-only callout that stays hidden until the registration is paid. # Idempotent: only seeded when the workshop has no callouts yet, so admin edits survive. art_workshop = Event.find_by(title: "Mindful Art for Survivors Workshop") -if art_workshop && art_workshop.registration_ticket_callouts.none? +if art_workshop && art_workshop.registration_ticket_callouts.custom.none? art_workshop.registration_ticket_callouts.create!( [ { @@ -644,7 +658,7 @@ callouts_by_event.each do |event_title, component_keys| event = Event.find_by(title: event_title) - next unless event && event.registration_ticket_callouts.none? + next unless event && event.registration_ticket_callouts.custom.none? component_keys.each_with_index do |key, i| event.registration_ticket_callouts.create!(component_callouts.fetch(key).merge(position: i + 1)) diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index ac5aabacc..ecc92c63e 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -243,7 +243,7 @@ } expect(event.registration_ticket_callouts.builtin.pluck(:builtin_key)).to contain_exactly( - "payment", "certificate", "scholarship", "ce_hours", "event_details", + "payment", "certificate", "scholarship", "ce_hours", "art_supplies", "videoconference", "handouts", "faq" ) end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index bf08395bd..c43dd0156 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -182,63 +182,6 @@ def offer_ce!(target_event) end end - describe "GET /details" do - let(:event) { create(:event, :published, :publicly_visible) } - - it "renders the details page when details are present" do - event.update!(event_details_label: "Art supplies", event_details: "

      Bring scissors

      ") - get details_event_path(event) - expect(response).to have_http_status(:ok) - expect(response.body).to include("Art supplies") - expect(response.body).to include("Bring scissors") - end - - it "renders each
      disclosure as a collapsible section" do - event.update!(event_details_label: "Art supplies & what to bring", - event_details: "

      Bring what you like.

      Workshop 1
      • Clear glass stones
      ") - get details_event_path(event) - expect(response).to have_http_status(:ok) - # Text outside a disclosure stays open; the disclosure becomes a styled toggle card. - expect(response.body).to include("Bring what you like.") - expect(response.body).to include("Email your license number

      ") - get ce_hours_event_path(event) - expect(response).to have_http_status(:ok) - expect(response.body).to include("Continuing education") - expect(response.body).to include("Email your license number") - end - - it "redirects to the event when the event is not CE-eligible" do - get ce_hours_event_path(event) - expect(response).to redirect_to(event_path(event)) - end - - it "surfaces the CE deadlines when set" do - event.update!(ce_hours_offered: 6, ce_hours_details: "

      CE info

      ", - ce_hours_request_deadline: Date.new(2026, 7, 1), - ce_payment_due_deadline: Date.new(2026, 8, 15)) - get ce_hours_event_path(event) - expect(response.body).to include("Request CE credit by") - expect(response.body).to include("July 1, 2026") - expect(response.body).to include("Payment due by") - expect(response.body).to include("August 15, 2026") - end - end - describe "GET /new" do context "as admin" do it "renders successfully" do @@ -295,11 +238,11 @@ def offer_ce!(target_event) expect(response.body).to include(VisibilityFlagsHelper::FLAG_DEFINITIONS[:public_registration_enabled][:description]) end - it "renders the built-in 'Before you attend' card as an editable row" do + it "renders the built-in 'Art supplies & what to bring' card as an editable row" do get edit_event_path(event) expect(response.body).to include("Registration ticket callouts") - expect(response.body).to include("Before you attend") - # Its text now lives on the callout row, not the event columns. + expect(response.body).to include("Art supplies & what to bring") + # Its text lives on the callout row, not the removed event columns. expect(response.body).not_to include("event[event_details_label]") end diff --git a/spec/services/builtin_callout_cards_spec.rb b/spec/services/builtin_callout_cards_spec.rb index 0dbb5c9b8..4e6017c59 100644 --- a/spec/services/builtin_callout_cards_spec.rb +++ b/spec/services/builtin_callout_cards_spec.rb @@ -34,11 +34,13 @@ def add_scholarship_form(event) end it "skips a built-in card the event has materialized (it renders from the row instead)" do - event.update!(event_details: "Bring supplies") - create(:registration_ticket_callout, event:, builtin_key: "event_details", title: "Before you attend") + event.update!(ce_hours_offered: 6) + license = create(:professional_license, :placeholder, person: registration.registrant) + create(:continuing_education_registration, event_registration: registration, professional_license: license) + create(:registration_ticket_callout, event:, builtin_key: "ce_hours", title: "CE hours") # Materialized, so the code fallback omits it — the row renders it instead. - expect(card_titles(registration)).not_to include(event.event_details_label) + expect(card_titles(registration.reload)).not_to include("CE hours") end it "makes the payment card an action card while a balance is due, reference once paid" do @@ -57,7 +59,7 @@ def add_scholarship_form(event) end it "uses the arrow trailing icon for every card" do - event.update!(ce_hours_details: "6 hours", videoconference_url: "https://example.zoom.us/j/1") + event.update!(videoconference_url: "https://example.zoom.us/j/1") trailing = described_class.new(registration).cards.map(&:trailing_icon).uniq expect(trailing).to eq([ "fa-solid fa-arrow-right" ]) end @@ -78,16 +80,16 @@ def add_scholarship_form(event) it "shows the CE card only when the registrant requested CE credit" do event.update!(ce_hours_offered: 6) - expect(card_titles(registration)).not_to include(event.ce_hours_details_label) + expect(card_titles(registration)).not_to include(event.ce_hours_label) license = create(:professional_license, :placeholder, person: registration.registrant) create(:continuing_education_registration, event_registration: registration, professional_license: license) - expect(card_titles(registration.reload)).to include(event.ce_hours_details_label) + expect(card_titles(registration.reload)).to include(event.ce_hours_label) end it "omits the CE card when the event offers no CE hours, even with a CE registration" do license = create(:professional_license, :placeholder, person: registration.registrant) create(:continuing_education_registration, event_registration: registration, professional_license: license) - expect(card_titles(registration.reload)).not_to include(event.ce_hours_details_label) + expect(card_titles(registration.reload)).not_to include(event.ce_hours_label) end it "omits the scholarship card when the event has no scholarship form, even when requested" do @@ -101,7 +103,7 @@ def add_scholarship_form(event) ce = create(:continuing_education_registration, event_registration: registration, professional_license: license) # Balance due, license still needed: orange card, amber chip naming what's needed. - needs = card(registration.reload, event.ce_hours_details_label) + needs = card(registration.reload, event.ce_hours_label) expect(needs.theme).to eq(DomainTheme.swatch("orange")) expect(needs.subtitle).to eq("6 hours") expect(needs.badge).to eq("$150 · License number needed") @@ -109,14 +111,14 @@ def add_scholarship_form(event) # License provided but still owing: orange card, amber "$X due" chip like the payment card. license.update!(number: "LIC123") - due = card(registration.reload, event.ce_hours_details_label) + due = card(registration.reload, event.ce_hours_label) expect(due.theme).to eq(DomainTheme.swatch("orange")) expect(due.badge).to eq("$150 due") expect(due.badge_classes).to be_nil # Paid in full: resting teal card, no "due" chip. create(:allocation, allocatable: ce, amount: 15_000) - paid = card(registration.reload, event.ce_hours_details_label) + paid = card(registration.reload, event.ce_hours_label) expect(paid.theme).to eq(DomainTheme.swatch("teal")) expect(paid.badge).to be_nil end @@ -125,17 +127,17 @@ def add_scholarship_form(event) event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000, ce_hours_request_deadline: Date.new(2026, 7, 1)) license = create(:professional_license, :placeholder, person: registration.registrant) create(:continuing_education_registration, event_registration: registration, professional_license: license) - expect(card(registration.reload, event.ce_hours_details_label).badge).to eq("$150 · License number needed by Jul 1") + expect(card(registration.reload, event.ce_hours_label).badge).to eq("$150 · License number needed by Jul 1") end it "appends the CE payment deadline to the amount-due badge, dropping it once paid" do event.update!(ce_hours_offered: 6, ce_hours_cost_cents: 15_000, ce_payment_due_deadline: Date.new(2026, 8, 15)) license = create(:professional_license, person: registration.registrant, number: "LIC123") ce_reg = create(:continuing_education_registration, event_registration: registration, professional_license: license) - expect(card(registration.reload, event.ce_hours_details_label).badge).to eq("$150 due by Aug 15") + expect(card(registration.reload, event.ce_hours_label).badge).to eq("$150 due by Aug 15") create(:allocation, source: create(:payment), allocatable: ce_reg, amount: ce_reg.cost_cents) - expect(card(registration.reload, event.ce_hours_details_label).badge).to be_nil + expect(card(registration.reload, event.ce_hours_label).badge).to be_nil end it "shows the scholarship card only when requested, without an amount chip until awarded" do @@ -189,9 +191,8 @@ def add_scholarship_form(event) end 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, + # Handouts/FAQ/art supplies are row-driven, so they never appear in this fallback. + event.update!(facilitator_training: true, ce_hours_offered: 6, videoconference_url: "https://example.zoom.us/j/123", start_date: 3.days.ago, end_date: 2.days.ago) add_scholarship_form(event) @@ -202,8 +203,7 @@ def add_scholarship_form(event) "Make your payment", "Certificate of completion", "Scholarship", - event.ce_hours_details_label, - event.event_details_label, + event.ce_hours_label, "Videoconference" ]) end diff --git a/spec/services/builtin_callouts_spec.rb b/spec/services/builtin_callouts_spec.rb index 4730de0ff..737161257 100644 --- a/spec/services/builtin_callouts_spec.rb +++ b/spec/services/builtin_callouts_spec.rb @@ -8,7 +8,7 @@ built = described_class.build(event) expect(built.map(&:builtin_key)).to contain_exactly( - "payment", "certificate", "scholarship", "ce_hours", "event_details", + "payment", "certificate", "scholarship", "ce_hours", "art_supplies", "videoconference", "handouts", "faq" ) expect(built).to all(be_new_record) @@ -46,7 +46,7 @@ keys = event.registration_ticket_callouts.builtin.pluck(:builtin_key) expect(keys).to contain_exactly( - "payment", "certificate", "scholarship", "ce_hours", "event_details", + "payment", "certificate", "scholarship", "ce_hours", "art_supplies", "videoconference", "handouts", "faq" ) end @@ -54,13 +54,13 @@ it "seeds callouts in canonical ticket order" do form = create(:form) event = create(:event, facilitator_training: true, ce_hours_offered: 6, - event_details: "

      x

      ", videoconference_url: "https://example.com/z") + videoconference_url: "https://example.com/z") event.event_forms.create!(form:, role: "scholarship") described_class.seed(event) expect(event.registration_ticket_callouts.ordered.map(&:builtin_key)).to eq( - %w[payment certificate scholarship ce_hours event_details videoconference handouts faq] + %w[payment certificate scholarship ce_hours art_supplies videoconference handouts faq] ) end @@ -110,23 +110,30 @@ expect(payment.resources).to be_empty # no W-9 on a free event end - it "migrates CE hours and event-details content from the event onto the row" do - event = create(:event, ce_hours_details_label: "Continuing education", - ce_hours_details: "

      CAMFT approved.

      ", event_details_label: "Art supplies", - event_details: "

      Bring paper.

      ") + it "seeds CE hours and art supplies with their default titles and no content" do + event = create(:event) described_class.seed(event) 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") - expect(details.description).to eq("

      Bring paper.

      ") - # A freshly-migrated card matches its default. + art_supplies = event.registration_ticket_callouts.find_by(builtin_key: "art_supplies") + expect(ce.title).to eq("CE hours") + expect(ce.description).to be_blank + expect(art_supplies.title).to eq("Art supplies & what to bring") + expect(art_supplies.description).to be_blank + # A freshly-seeded card matches its default. expect(described_class.customized?(ce)).to be(false) end + it "seeds art supplies as a content callout" do + event = create(:event) + + described_class.seed(event) + + art_supplies = event.registration_ticket_callouts.find_by(builtin_key: "art_supplies") + expect(art_supplies.behavioral_builtin?).to be(false) + end + it "reports whether a materialized callout has been customized" do event = create(:event, facilitator_training: true) described_class.seed(event) @@ -231,7 +238,7 @@ expect(event.registration_ticket_callouts.ordered.first).to eq(custom) expect(event.registration_ticket_callouts.ordered.map(&:builtin_key).compact).to eq( - %w[payment certificate scholarship ce_hours event_details videoconference handouts faq] + %w[payment certificate scholarship ce_hours art_supplies videoconference handouts faq] ) end end diff --git a/spec/system/event_registration_show_spec.rb b/spec/system/event_registration_show_spec.rb index db88b5bb5..22e18f3a2 100644 --- a/spec/system/event_registration_show_spec.rb +++ b/spec/system/event_registration_show_spec.rb @@ -59,21 +59,26 @@ end end - describe "before-you-attend call-out" do - it "links to the details page using the event's label when details are present" do - event.update!(event_details_label: "Art supplies", event_details: "

      Bring scissors

      ") + describe "art supplies call-out" do + it "links to its callout page when published with content" do + callout = create(:registration_ticket_callout, event:, builtin_key: "art_supplies", + title: "Art supplies & what to bring", description: "

      Bring scissors

      ", hidden: false) sign_in(user) visit registration_ticket_path(registration.slug) - expect(page).to have_link("Art supplies", href: details_event_path(event, reg: registration.slug)) + expect(page).to have_link("Art supplies & what to bring", + href: event_registration_ticket_callout_path(event, callout, reg: registration.slug)) end - it "is hidden when no details are set" do + it "is hidden when the callout is not published" do + create(:registration_ticket_callout, event:, builtin_key: "art_supplies", + title: "Art supplies & what to bring", description: "

      Bring scissors

      ", hidden: true) + sign_in(user) visit registration_ticket_path(registration.slug) - expect(page).to have_no_link(href: details_event_path(event, reg: registration.slug)) + expect(page).to have_no_link("Art supplies & what to bring") end end diff --git a/spec/views/page_bg_class_alignment_spec.rb b/spec/views/page_bg_class_alignment_spec.rb index 19c748f66..06e77ddf6 100644 --- a/spec/views/page_bg_class_alignment_spec.rb +++ b/spec/views/page_bg_class_alignment_spec.rb @@ -210,8 +210,6 @@ "app/views/events/callouts/handouts.html.erb" => "public", "app/views/events/callouts/resource.html.erb" => "public", "app/views/events/callouts/videoconference.html.erb" => "public", - "app/views/events/details.html.erb" => "public", - "app/views/events/ce_hours.html.erb" => "public", "app/views/registration_ticket_callouts/show.html.erb" => "public", # ─── bulk payment views ─── From ddad6d54215c728dcf1c4c1b07c15a3c094ff7b0 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 15 Jul 2026 01:49:19 -0400 Subject: [PATCH 3/4] Update AGENTS.md for art_supplies content callout + dropped columns Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 3066dc05fc2ac706c5f2f2efde6f15c9f770b8b5 Mon Sep 17 00:00:00 2001 From: maebeale Date: Wed, 15 Jul 2026 02:02:55 -0400 Subject: [PATCH 4/4] Show scholarship + CE cards in the sample-ticket preview regardless of event config #1992 added config_gap guards to the callout card builders but didn't exempt the admin sample-ticket preview, so its 'every option turned on' view could no longer show the scholarship / CE cards on an event lacking a scholarship form or CE hours (and events_spec's sample_ticket ?options=all has failed since). The preview illustrates a hypothetical registrant's options, so it now bypasses config_gap for those two cards while a real ticket still applies it. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/services/builtin_callout_cards.rb | 11 ++++++++--- app/views/event_registrations/_ticket.html.erb | 2 +- spec/services/builtin_callout_cards_spec.rb | 11 +++++++++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index 8d6ec694c..83c155b2c 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -92,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 @@ -168,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 @@ -205,7 +210,7 @@ def scholarship_badge(awarded, tasks_outstanding) # they have, becoming a reference card once requested with hours and a license # number on file. Shown when the event offers CE or the registrant asked for it. def ce_hours_card - return if 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 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) %>