diff --git a/AGENTS.md b/AGENTS.md index 59e452e034..12d579d53e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,8 @@ This codebase (Rails 8.1) | `Event` | Events with registrations, featured/published states | | `EventStaff` | Join model connecting `Person` to `Event` as staff (title, `expected_to_attend`); drives the "Meet the staff" roster and "My events" | | `EventRegistrationChecklistCompletion` | Audited completion row for one manual onboarding step on an `EventRegistration` (`step` from `EventRegistration::CHECKLIST_STEPS`, `completed_by` User, `completed_at`); row-exists = done. Powers the event Onboarding tab's checkbox matrix | -| `RegistrationTicketCallout` | Admin-configured call-outs shown on an event's registration ticket (title, subtitle, HTML description, `callout_type` action/reference, icon/colour, `payment_access_gated` — only shown once the registrant has `payment_access_granted?` (paid or intends to pay), draggable `position` via the positioning gem); each links to its own public detail page | +| `RegistrationTicketCallout` | Call-outs shown on an event's registration ticket (title, subtitle, HTML description, `callout_type` action/reference, icon/colour, `payment_access_gated` — only shown once the registrant has `payment_access_granted?`, draggable `position`, `hidden` draft/opt-out, `display_from` drip date, and `has_many :resources` through `RegistrationTicketCalloutResource`); each links to its own public detail page. A nil `magic_key` is an admin-authored callout; a set `magic_key` is a built-in card materialized by `DefaultTicketCallouts` (hidden instead of deleted, restorable to default) | +| `RegistrationTicketCalloutResource` | Ordered join linking a `RegistrationTicketCallout` to the `Resource`s shown on its detail page | | `Story` | Editorial content with facilitators, primary/gallery assets | | `Resource` | Handouts, toolkits, templates with downloadable assets | | `Person` | Organization affiliates with contacts, addresses, sectors | @@ -204,7 +205,8 @@ end - `EventRegistrationServices::PublicRegistration` — Public registration handling - `EventRegistrationReadiness` — Computes a registration's lifecycle `status` (`:not_ready` → `:ready` → `:certificate_due` → `:completed`) from a pre-event "event ready" checklist, a post-event "completion work" checklist (attendance, scholarship tasks), and certificate delivery, returning the specific outstanding reasons. Reads payment/certificate state via `Registerable` (`paid_in_full?`, `certificate_sent?`) on both the registration and its `continuing_education_registrations`. Drives the registrants roster's single far-right Status badge column (with a short reason under "Not ready" and a cert-type note under "Certificate pending") and its matching filter - `ReminderRecipientFilter` — Decides which event registrations stay checked on the bulk reminder page given the admin's filters (matches in memory, returns matching ids) -- `MagicTicketCallouts` — Code-defined ("magic") ticket callout cards (payment, certificate, scholarship, CE hours, art supplies, forms, handouts, portal, videoconference, FAQ), each with its own visibility rule; rendered through the same `_callout_card` partial as admin-configured `RegistrationTicketCallout`s. Their public show pages live under `app/views/events/callouts/` and are served by `Events::CalloutsController` (slug-authorized, no login) +- `MagicTicketCallouts` — Code-defined ("magic") ticket callout cards (payment, certificate, scholarship, CE hours, art supplies, forms, handouts, portal, videoconference, FAQ), each with its own visibility rule; rendered through the same `_callout_card` partial as `RegistrationTicketCallout`s. Skips any card an event has materialized (see `DefaultTicketCallouts`) so the two paths never double-render, and serves as the fallback for events not yet seeded. Public show pages live under `app/views/events/callouts/` (`Events::CalloutsController`, slug-authorized) +- `DefaultTicketCallouts` — Materializes all built-in callouts into `RegistrationTicketCallout` rows in canonical ticket order, seeded on event create and lazily on edit, each gated by the event's config via `seed_if` (idempotent, no backfill). Built-ins are edited in the **same** callout-fields row as custom callouts (pre-filled title/subtitle/colour/icon/callout-page-text/resources; hidden instead of deleted; "Restore default" shown only when `.customized?`). "Content" cards (Handouts, FAQ) render their own copy/resources; "behavioral" cards render live status through `MagicTicketCallouts#card_for`, which overlays the app's badge/visibility/destination on the row's editable presentation. Behavioral pages show the row's callout-page-text as an intro (`@builtin_intro`) and any linked resources below it. Certificate is opt-in (default off except trainings); Videoconference drips a week before start via `display_from`. CE hours and Event details are edited like every other built-in — their title/text seed from the event's columns once (migrating existing content) then live on the row; the CE hours-offered/cost config still edits the event inline via `event_f` (`ce_config?`). The registrant CE/details pages read the row (falling back to the event columns pre-seed). Built-ins always seed and also materialize lazily on `edit`, so the editor shows the full set; the editor shows "Restore default" (or a static "Matches default") per row via `.customized?`. The visibility control is a `published` toggle (inverse of `hidden`) ### Affiliations diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index 253d997144..b2a3c700bb 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -8,6 +8,9 @@ class CalloutsController < ApplicationController before_action :set_event_registration before_action :authorize_callout before_action :set_event + # These pages carry an editable intro (the built-in row's "Callout page text") + # above the app-controlled content, plus any resources linked to the row. + before_action :set_builtin_content, only: %i[ payment scholarship certificate videoconference forms ] # Hidden Resource (by title) backing the handout links, in display order. # Missing ones (e.g. not seeded in an environment) are silently skipped. @@ -177,6 +180,16 @@ def set_event @event = @event_registration.event end + # The editable intro and linked resources for a built-in page, from the + # materialized callout row for this action's magic_key. Nil/empty when the + # event hasn't materialized the card. Forms renders its own document list + # (W-9 + invoice/receipt) via #build_form_cards, so it skips the resources here. + def set_builtin_content + callout = @event.registration_ticket_callouts.find_by(magic_key: action_name) + @builtin_intro = callout&.description.presence + @builtin_resources = callout && action_name != "forms" ? callout.resources.to_a : [] + end + # Update form submission if ce record is updated via callout def record_ce_license_answer(number) form = @event.continuing_education_form @@ -188,16 +201,16 @@ def record_ce_license_answer(number) answer.update!(submitted_answer: number.to_s, question_name_when_answered: field.name) end - # Builds the callout-card links shown on the forms page. The W-9 opens in its - # own resource page (preview + download) when seeded; the invoice and the - # paid-in-full receipt (once settled) show for paid events. Each returns to forms. + # Builds the callout-card links shown on the forms page. The document links + # come from the materialized Forms callout's resources (the W-9 by default, + # editable/removable per event) — or the hard-coded W-9 for events not yet + # materialized. The invoice and paid-in-full receipt (once settled) show for + # paid events. Each returns to forms. def build_form_cards - cards = [] - w9 = Resource.find_by(title: "W-9") - if w9 - cards << resource_card(icon: "fa-solid fa-file-pdf", title: "W-9", - subtitle: "AWBW's W-9 tax form for your records", - href: registration_resource_path(@event_registration.slug, w9, return_to: "forms"), target: nil) + cards = form_document_resources.map do |resource| + resource_card(icon: "fa-solid fa-file-pdf", title: resource.title, + subtitle: form_resource_subtitle(resource), + href: registration_resource_path(@event_registration.slug, resource, return_to: "forms"), target: nil) end if @event_registration.invoice_available? cards << resource_card(icon: "fa-solid fa-file-invoice-dollar", title: "View invoice", @@ -212,6 +225,20 @@ def build_form_cards cards end + # The forms callout's linked documents. Uses the materialized Forms row's + # resources when present (so admins can add/remove them), else the W-9 for + # events not yet materialized. + def form_document_resources + forms_callout = @event.registration_ticket_callouts.find_by(magic_key: "forms") + return forms_callout.resources.to_a if forms_callout + Resource.where(title: "W-9").to_a + end + + def form_resource_subtitle(resource) + return "AWBW's W-9 tax form for your records" if resource.title == "W-9" + "Open this document" + end + # A blue callout card linking to a document. External/static links open in a # new tab (target: "_blank"); registrant resource pages stay in-tab so the # back-to-ticket eyebrow works (pass target: nil). diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index e8eaf11cbe..88a23e0fbb 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -35,6 +35,9 @@ def new def edit authorize! @event + # Materialize any missing built-in callouts so the editor shows them all + # (idempotent; heals events created before a built-in existed). + DefaultTicketCallouts.seed(@event) set_form_variables end @@ -188,7 +191,11 @@ def onboarding def details authorize! @event, to: :details? - if @event.event_details.blank? + callout = @event.registration_ticket_callouts.find_by(magic_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 @@ -445,6 +452,7 @@ def create if params.dig(:library_asset, :new_assets).present? update_asset_owner(@event) end + DefaultTicketCallouts.seed(@event) success = true end rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved, ActiveRecord::RecordNotUnique => e @@ -473,6 +481,9 @@ def update @event.event_forms.reset if @event.update(event_params) assign_associations(@event) + # Lazily materialize built-in callouts for events created before this + # existed — heals on first edit, no data backfill. Idempotent. + DefaultTicketCallouts.seed(@event) success = true else raise ActiveRecord::Rollback diff --git a/app/controllers/registration_ticket_callouts_controller.rb b/app/controllers/registration_ticket_callouts_controller.rb index acd4f9100d..61cbd9d5f6 100644 --- a/app/controllers/registration_ticket_callouts_controller.rb +++ b/app/controllers/registration_ticket_callouts_controller.rb @@ -10,7 +10,13 @@ def show @callout = @event.registration_ticket_callouts.find(params[:id]) authorize! @callout, to: :show? - if @callout.description.blank? && @callout.resource.nil? + # A hidden (draft/opted-out) or not-yet-dripped callout has no public page. + if @callout.hidden? || @callout.dripping? + redirect_to event_path(@event, reg: params[:reg].presence) + return + end + + if @callout.description.blank? && @callout.resources.empty? redirect_to event_path(@event, reg: params[:reg].presence) return end diff --git a/app/models/event.rb b/app/models/event.rb index 69f8e0e62f..d65fb096aa 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -47,7 +47,10 @@ class Event < ApplicationRecord accepts_nested_attributes_for :event_staffs, allow_destroy: true, reject_if: proc { |attrs| attrs["person_id"].blank? } accepts_nested_attributes_for :registration_ticket_callouts, allow_destroy: true, - reject_if: proc { |attrs| attrs["title"].blank? } + # Reject only blank *new* callouts; existing rows (with an id) must always + # process so a control-only magic row's hidden/position toggle persists even + # though it submits no title. + reject_if: proc { |attrs| attrs["id"].blank? && attrs["title"].blank? } # Callbacks after_commit :build_public_registration_form, if: :public_registration_just_enabled? diff --git a/app/models/registration_ticket_callout.rb b/app/models/registration_ticket_callout.rb index 1ed9edc765..111f8680d7 100644 --- a/app/models/registration_ticket_callout.rb +++ b/app/models/registration_ticket_callout.rb @@ -5,23 +5,59 @@ class RegistrationTicketCallout < ApplicationRecord # group them on the ticket later. CALLOUT_TYPES = %w[ action reference ].freeze + # Hidden identifiers for the built-in ("magic") callouts. A row carrying one of + # these was seeded from a code-defined default (see DefaultTicketCallouts) and + # keeps its ticket behavior — badges, per-registration visibility — driven by + # that key. Admin-authored callouts have a nil magic_key. Magic callouts are + # hidden rather than destroyed so they can be restored to their default. + MAGIC_KEYS = %w[ + payment certificate scholarship ce_hours event_details + videoconference forms handouts faq + ].freeze + + # "Content" magic callouts render their own editable copy/resources (like custom + # callouts). "Behavioral" magic callouts (the rest) render live per-registration + # status through MagicTicketCallouts#card_for — the row still owns the editable + # title/subtitle/text, order, visibility, and resources. + CONTENT_MAGIC_KEYS = %w[ handouts faq ].freeze + + # Behavioral built-ins that also carry event-level config edited inline in their + # row (CE hours offered / cost); their text lives on the row like everything else. + CONFIG_MAGIC_KEYS = %w[ ce_hours ].freeze + + # Built-ins whose card colour the app sets from live status — Payment turns + # orange while a balance is due, and Scholarship / CE hours turn amber while the + # registrant has something outstanding. For these, the app colour overrides the + # selected one (see MagicTicketCallouts#card_for). + APP_COLORED_MAGIC_KEYS = %w[ payment scholarship ce_hours ].freeze + # Per-type fallbacks for the icon and colour. These are callout-specific (unlike # the generic colour swatches and palette, which live in DomainTheme so the whole # app can reuse them for tinted boxes — amount-due, scholarship box, etc.). - DEFAULT_ICONS = { "action" => "fa-solid fa-arrow-right", "reference" => "fa-solid fa-circle-info" }.freeze + # The leading icon defaults to the info "i"; the trailing "go to page" arrow + # lives on the right (see _callout_card). Both types share the same leading + # fallback so a callout without a custom icon always reads as informational. + DEFAULT_ICONS = { "action" => "fa-solid fa-circle-info", "reference" => "fa-solid fa-circle-info" }.freeze DEFAULT_COLORS = { "action" => "orange", "reference" => "indigo" }.freeze - # New callouts start with the arrow icon pre-filled (the "action" default) so - # admins see a sensible value rather than an empty field. Loaded records keep - # their stored value; a blank one still falls back via #display_icon_class. + # New callouts start with the info icon pre-filled so admins see a sensible + # value rather than an empty field. Loaded records keep their stored value; a + # blank one still falls back via #display_icon_class. attribute :icon_class, :string, default: -> { DEFAULT_ICONS["action"] } belongs_to :event - # Optionally links the callout to a Resource. When present, the callout's - # detail page renders the resource's display (PDF first-page preview, etc.) - # and a download button beneath the callout's own title/subtitle/content. - belongs_to :resource, optional: true + # A callout can link many resources, shown in order on its detail page (PDF + # previews + download buttons) beneath its own title/subtitle/content — e.g. + # the Handouts card's worksheets, or a custom callout's supporting documents. + has_many :registration_ticket_callout_resources, -> { ordered }, dependent: :destroy, + inverse_of: :registration_ticket_callout + has_many :resources, through: :registration_ticket_callout_resources + + # Linked resources are added one dropdown at a time in the editor (cocoon + # add/remove), like Sectors on a Person. Blank picks are dropped. + accepts_nested_attributes_for :registration_ticket_callout_resources, allow_destroy: true, + reject_if: proc { |attrs| attrs["resource_id"].blank? } # Per-event ordering, drag-reordered after save via the shared `sortable` # Stimulus controller (a per-row PUT to #update). The gem reflows the other @@ -33,13 +69,75 @@ class RegistrationTicketCallout < ApplicationRecord validates :callout_type, inclusion: { in: CALLOUT_TYPES } validates :color_class, inclusion: { in: DomainTheme::SWATCH_COLORS.map(&:to_s) }, allow_blank: true validates :position, numericality: { only_integer: true, greater_than: 0, allow_nil: true } + validates :magic_key, inclusion: { in: MAGIC_KEYS }, allow_nil: true + validates :magic_key, uniqueness: { scope: :event_id }, allow_nil: true scope :ordered, -> { order(:position, :id) } + scope :visible, -> { where(hidden: false) } + scope :magic, -> { where.not(magic_key: nil) } + scope :custom, -> { where(magic_key: nil) } + + # Reset flagged rows to their template after the save that set the flag. The + # flag is cleared first so the reset's own update doesn't recurse. + after_save :apply_reset_to_default, if: :reset_to_default? + + def apply_reset_to_default + self.reset_to_default = nil + DefaultTicketCallouts.reset(self) + end def action? callout_type == "action" end + # The editor exposes visibility as "Published" — the inverse of the stored + # `hidden` flag — so a checked box means the callout shows on the ticket. + def published + !hidden + end + alias_method :published?, :published + + def published=(value) + self.hidden = !ActiveModel::Type::Boolean.new.cast(value) + end + + # Set from the editor's "Restore default" checkbox. When checked, the row is + # reset to its built-in template as part of the normal event save — no separate + # request — overriding whatever else was submitted for it. + attr_accessor :reset_to_default + + def reset_to_default? + magic? && ActiveModel::Type::Boolean.new.cast(reset_to_default) + end + + # A seeded built-in callout (Handouts, FAQ, …) rather than an admin-authored + # one. Magic callouts hide instead of delete and can be reset to default. + def magic? + magic_key.present? + end + + # A behavioral built-in callout whose card is rendered by MagicTicketCallouts + # (live status), as opposed to a content callout that renders from its own row. + def behavioral_magic? + magic? && CONTENT_MAGIC_KEYS.exclude?(magic_key) + end + + # Whether the row carries the inline CE config fields (hours offered / cost). + def ce_config? + CONFIG_MAGIC_KEYS.include?(magic_key.to_s) + end + + # Whether the app sets this card's colour from live status, overriding the + # selected colour (so the editor notes it under the colour picker). + def app_colored? + APP_COLORED_MAGIC_KEYS.include?(magic_key.to_s) + end + + # Whether the callout is drip-scheduled to appear only from a future date. + def dripping?(now = Time.current) + display_from.present? && display_from > now + end + # Font Awesome class for the leading icon, falling back to a sensible default # per callout type so a callout never renders without an icon. def display_icon_class diff --git a/app/models/registration_ticket_callout_resource.rb b/app/models/registration_ticket_callout_resource.rb new file mode 100644 index 0000000000..f3f2a12f01 --- /dev/null +++ b/app/models/registration_ticket_callout_resource.rb @@ -0,0 +1,14 @@ +class RegistrationTicketCalloutResource < ApplicationRecord + # Ordered join between a callout and the resources it links to (e.g. the + # Handouts card's worksheets, or a custom callout's supporting documents). + # Reordered like the callouts themselves via the positioning gem. + belongs_to :registration_ticket_callout + belongs_to :resource + + positioned on: :registration_ticket_callout_id + + validates :resource_id, uniqueness: { scope: :registration_ticket_callout_id } + validates :position, numericality: { only_integer: true, greater_than: 0, allow_nil: true } + + scope :ordered, -> { order(:position, :id) } +end diff --git a/app/policies/event_policy.rb b/app/policies/event_policy.rb index c9d2c0a5b9..d25f0edb02 100644 --- a/app/policies/event_policy.rb +++ b/app/policies/event_policy.rb @@ -159,7 +159,8 @@ def google_analytics? sector_ids: [], primary_asset_attributes: [ :id, :file, :_destroy ], gallery_assets_attributes: [ :id, :file, :_destroy ], - registration_ticket_callouts_attributes: [ :id, :title, :subtitle, :description, :callout_type, :icon_class, :color_class, :payment_access_gated, :resource_id, :_destroy ] + registration_ticket_callouts_attributes: [ :id, :title, :subtitle, :description, :callout_type, :icon_class, :color_class, :display_from, :payment_access_gated, :published, :reset_to_default, :_destroy, + { registration_ticket_callout_resources_attributes: [ :id, :resource_id, :_destroy ] } ] ] permitted.prepend(:ga4_snippet, :gtm_head_snippet, :gtm_body_snippet) if admin? diff --git a/app/services/default_ticket_callouts.rb b/app/services/default_ticket_callouts.rb new file mode 100644 index 0000000000..bc9417938f --- /dev/null +++ b/app/services/default_ticket_callouts.rb @@ -0,0 +1,281 @@ +# Materializes the built-in ("magic") ticket callouts into editable rows for an +# event. Run on event create and, for events that predate this, lazily on edit — +# so existing events heal the first time they're saved, with no data backfill. +# +# All nine built-ins always seed. Their initial visibility (hidden/published) is +# derived from the event's config via each definition's `hidden` proc: published +# by default on facilitator trainings, hidden (unchecked) on everything else. +# Admins toggle visibility per row from the editor. Seeding is idempotent so a +# re-run never clobbers admin edits. +# +# Behavioral built-ins (payment, CE, scholarship, …) render live per-registration +# status through MagicTicketCallouts#card_for, which has its own guards (e.g. the +# payment card returns nil on a free event) so publishing a row on an +# non-applicable event is harmless. MagicTicketCallouts skips any card an event +# has already materialized, so the two paths never double-render. +class DefaultTicketCallouts + # Default FAQ content for the 2-day training, mirrored from the code-defined FAQ + # page so a newly materialized card starts with the current copy. Admins edit it + # per event afterward. + FAQS = [ + { q: "Who is this training designed for?", + a: [ "This training is designed for anyone interested in incorporating healing arts into the work they do with individuals, groups, or communities. Participants come from a wide range of backgrounds, including education, mental health, social services, healthcare, community organizing, advocacy, and nonprofit work." ] }, + { q: "Do I need to be an artist or have art experience to participate?", + a: [ "No prior art experience or artistic background is required to participate in the training. The AWBW approach is centered on the creative process rather than artistic skill or technique." ] }, + { q: "Is the training trauma-informed?", + a: [ "Yes. The AWBW model, philosophy, and facilitation approach are rooted in trauma-informed practices. The training includes dedicated content focused on trauma-informed facilitation and creating supportive, inclusive environments for participants." ] }, + { q: "Do I need to work for an organization to participate in the training or become a facilitator?", + a: [ "No. You do not need to be affiliated with an organization or agency to participate in the training or become an AWBW Facilitator. While many facilitators implement the workshops within organizations, schools, or community programs, others use the workshops in private practice, community spaces, support groups, creative gatherings, or personal healing work. The workshops are designed to be flexible and adaptable across a variety of settings and populations." ] }, + { q: "Can I use the workshops with youth? Adults? Families?", + a: [ "Yes. AWBW workshops are designed to be flexible and adaptable for individuals of all ages, including youth, adults, families, and intergenerational groups.", + "Within the facilitator portal, workshops can be searched and filtered by focus area, population, theme, and other categories to help facilitators find workshops that best fit the communities they serve." ] }, + { q: "How many workshops are included in the curriculum library?", + a: [ "The AWBW facilitator portal includes access to a library of more than 600 art workshops and facilitation resources." ] }, + { q: "Are workshops available in Spanish or other languages?", + a: [ "Yes. A number of workshops have been translated into Spanish. Within the facilitator portal, facilitators can use the search filters to locate workshops that include Spanish translations.", + "AWBW continues to work toward increasing language accessibility and expanding translated resources over time." ] }, + { q: "What materials do I need for the art workshops?", + a: [ "There are no required art materials for participation. Workshops can be completed using any materials you already have available, such as paper, pencils, pens, crayons, markers, colored pencils, paint, collage materials, or other creative supplies. The focus of the AWBW approach is on the creative process rather than the materials themselves.", + "Prior to the training, we will also send participants a list of optional art supplies that may be helpful to have on hand, as well as printable worksheets for each of the art workshops that will be facilitated during the training. While neither the supplies nor the worksheets are required, many participants find them helpful for engaging more fully in the workshop experience.", + "We will walk through the art supplies suggested for each workshop so that, if trainees choose to use those materials in their own facilitation, they feel familiar and comfortable incorporating those materials into their own facilitation practice." ] }, + { q: "What ongoing support does AWBW provide after completing the on-demand training?", + a: [ "Once you complete the On-Demand Training and receive your certification, you become part of a thriving community of over 1,500 Windows Facilitators across the country and abroad, all learning from and supporting one another.", + "As a certified facilitator, you'll gain access to:" ], + list: [ "The AWBW Facilitator Portal, which houses a curriculum of over 600 art workshops, downloadable resources, toolkits, handouts, the Facilitator Manual, and tools for tracking attendance and gathering feedback", + "Ongoing professional development, including live art workshops, Q&As, and recorded presentations by subject matter experts", + "Virtual Community of Practice events, where you can connect with fellow facilitators and experience new workshops", + "AWBW staff support for workshop selection, facilitation questions, art supply tips, and more", + "A bi-monthly newsletter with new workshops, stories, and resources" ] }, + { q: "Can multiple staff members from one organization participate?", + a: [ "Yes. Multiple staff members from the same organization are welcome and encouraged to participate in the training.", + "Many organizations choose to train teams of staff members in order to integrate trauma-informed healing arts practices more broadly across their programs, services, and communities." ] }, + { q: "Are there any fees, other than the training fee, associated with becoming a facilitator?", + a: [ "AWBW has an annual membership fee of $100 per program to support the sustainability of the organization and continued facilitator resources and support.", + "The membership fee covers all facilitators connected to the same program or organization for the calendar year, regardless of the number of facilitators participating. Only one membership fee payment is required per program annually." ] } + ].freeze + + # magic_keys this service knows how to materialize. + def self.seedable_keys + new(nil).send(:definitions).map { |definition| definition[:magic_key] } + end + + def self.seed(event) + new(event).seed + end + + # Reset a materialized callout's content and default visibility back to its + # built-in template, keeping its position. Used by the "Restore default" action. + def self.reset(callout) + new(callout.event).reset(callout) + end + + # Whether a materialized callout has been edited away from its built-in + # template, so the editor can offer "Restore default" only when it applies. + def self.customized?(callout) + new(callout.event).customized?(callout) + end + + def initialize(event) + @event = event + end + + # Create any not-yet-present magic callouts for the event, appended in + # definition order after whatever already exists. All built-ins always seed; + # their initial visibility (hidden/published) is derived from the event's + # config via each definition's `hidden` proc. Seeding is idempotent so a re-run + # never clobbers admin edits. Returns the created rows. + def seed + existing_keys = @event.registration_ticket_callouts.magic.pluck(:magic_key).to_set + definitions.reject { |definition| existing_keys.include?(definition[:magic_key]) } + .map { |definition| create(definition) } + end + + def reset(callout) + definition = definitions.find { |candidate| candidate[:magic_key] == callout.magic_key } + return callout unless definition + + callout.update!( + title: resolve(definition[:title]), + subtitle: resolve(definition[:subtitle]), + description: resolve(definition[:description]), + callout_type: definition[:callout_type], + icon_class: definition[:icon_class], + color_class: definition[:color_class], + hidden: definition[:hidden].call(@event), + display_from: definition[:display_from]&.call(@event) + ) + callout.resources = definition[:resources]&.call || [] + callout + end + + def customized?(callout) + definition = definitions.find { |candidate| candidate[:magic_key] == callout.magic_key } + return false unless definition + + callout.title != resolve(definition[:title]) || + callout.subtitle != resolve(definition[:subtitle]) || + callout.description.to_s != resolve(definition[:description]).to_s || + callout.callout_type != definition[:callout_type] || + callout.icon_class != definition[:icon_class] || + callout.color_class != definition[:color_class] || + callout.hidden != definition[:hidden].call(@event) || + callout.display_from != definition[:display_from]&.call(@event) || + callout.resource_ids.sort != Array(definition[:resources]&.call).map(&:id).sort + end + + private + + # A definition value may be a static value or a proc taking the event (used by + # CE / event details to seed their default from the event's own columns). + def resolve(value) + value.respond_to?(:call) ? value.call(@event) : value + end + + # Ordered built-in callout definitions. `hidden` / `display_from` are procs so + # each event derives its own defaults; `resources` resolves the linked records; + # `seed_if` gates whether the card applies. Content cards (Handouts, FAQ) render + # their own copy; "behavioral" cards (Certificate, Videoconference) render live + # per-registration status through MagicTicketCallouts#card_for — the row only + # governs visibility, drip date, and order. + def definitions + [ + { + magic_key: "payment", + title: "Payment", + subtitle: "Your balance and payment history", + callout_type: "action", + icon_class: "fa-solid fa-credit-card", + color_class: "orange", + hidden: ->(event) { !event.facilitator_training? } + }, + { + magic_key: "certificate", + title: "Certificate of completion", + subtitle: "View and download your certificate", + callout_type: "action", + icon_class: "fa-solid fa-certificate", + color_class: "green", + # Off by default except on facilitator trainings. When shown, it still only + # appears once the certificate unlocks (MagicTicketCallouts guards this). + hidden: ->(event) { !event.facilitator_training? } + }, + { + magic_key: "scholarship", + title: "Scholarship", + subtitle: "Your scholarship request and award", + callout_type: "action", + icon_class: "fa-solid fa-award", + color_class: "fuchsia", + hidden: ->(event) { !event.facilitator_training? } + }, + { + magic_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 }, + subtitle: "Continuing education — requirements & how to request", + callout_type: "action", + icon_class: "fa-solid fa-graduation-cap", + color_class: "teal", + hidden: ->(event) { !event.facilitator_training? } + }, + { + magic_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 }, + subtitle: "Important info for this event — please read", + callout_type: "reference", + icon_class: "fa-solid fa-palette", + color_class: "blue", + hidden: ->(event) { !event.facilitator_training? } + }, + { + magic_key: "videoconference", + title: "Videoconference", + subtitle: "Join link and how to add it to your calendar", + callout_type: "action", + icon_class: "fa-solid fa-video", + color_class: "blue", + hidden: ->(event) { !event.facilitator_training? }, + # Drips onto the ticket a week before the event starts, replacing the old + # hard-coded "one week prior" rule with a stored, editable date. + display_from: ->(event) { event.start_date - 7.days if event.start_date } + }, + { + magic_key: "forms", + title: "Forms", + subtitle: "W-9, invoice, and receipt", + callout_type: "action", + icon_class: "fa-solid fa-file-lines", + color_class: "blue", + hidden: ->(event) { !event.facilitator_training? }, + # The W-9 is a removable linked resource, included by default only on paid + # events (where a tax form applies); invoice/receipt stay dynamic on the + # forms page. Admins add/remove it per event. + resources: -> { @event.cost_cents.to_i.positive? ? [ Resource.find_by(title: "W-9") ].compact : [] } + }, + { + magic_key: "handouts", + title: "Handouts", + subtitle: "Worksheets and resources for the training", + callout_type: "reference", + icon_class: "fa-solid fa-folder-open", + color_class: "blue", + hidden: ->(event) { !event.facilitator_training? }, + resources: -> { handout_resources } + }, + { + magic_key: "faq", + title: "Frequently asked questions", + subtitle: "Common questions about the 2-day training", + callout_type: "reference", + icon_class: "fa-solid fa-circle-question", + color_class: "blue", + description: faq_html, + hidden: ->(event) { !event.facilitator_training? } + } + ] + end + + def create(definition) + callout = @event.registration_ticket_callouts.create!( + magic_key: definition[:magic_key], + title: resolve(definition[:title]), + subtitle: resolve(definition[:subtitle]), + description: resolve(definition[:description]), + callout_type: definition[:callout_type], + icon_class: definition[:icon_class], + color_class: definition[:color_class], + hidden: definition[:hidden].call(@event), + display_from: definition[:display_from]&.call(@event) + ) + definition[:resources]&.call&.each { |resource| callout.resources << resource } + callout + end + + # The training worksheet resources, by title, in the display order the code + # card used. Missing ones (not seeded in an environment) are simply skipped. + def handout_resources + by_title = Resource.where(title: Events::CalloutsController::HANDOUT_RESOURCE_TITLES).index_by(&:title) + Events::CalloutsController::HANDOUT_RESOURCE_TITLES.filter_map { |title| by_title[title] } + end + + # Renders FAQS to the basic HTML the callout description accepts (headings, + # paragraphs, lists) so the materialized card reads like the code-defined page. + def faq_html + FAQS.map do |faq| + parts = [ "
#{h(paragraph)}
" } + parts << "- Call-outs shown on the registration ticket — each links to its own page. The built-in cards below are managed by the app, which decides when each appears; a few (shown in colour, with fields) let you edit their text here, while the rest (greyed out) are preview-only. Below them, add your + Call-outs shown on the registration ticket — each links to its own page. The built-in cards are seeded as editable rows (published by default on facilitator trainings, unchecked on everything else). Toggle a card's checkbox to show or hide it; the app still guards when each appears on the ticket (e.g. the payment card only shows when there's a balance). Below them, add your own call-outs for actions (forms to download, balances to pay) or reference reading (parking, policies, what to bring). New custom callouts save in the order shown; drag the handle to reorder saved callouts.
@@ -650,7 +650,7 @@ data-sortable-url-value="<%= @event.persisted? ? event_registration_ticket_callout_path(@event, ":id") : "" %>" > <%= f.fields_for :registration_ticket_callouts do |rts| %> - <%= render "events/registration_ticket_callout_fields", f: rts %> + <%= render "events/registration_ticket_callout_fields", f: rts, event_f: f %> <% end %><%= f.object.title %>
+" data-callout-preview-target="capperSubtitle"><%= f.object.subtitle %>
+The app overrides this with a live-status colour when the registrant has action to take (e.g. amber while something's outstanding, orange while a balance is due).
+ <% end %>Drips onto the ticket on and after this date. Leave blank to show right away.
+Shown on its own page. Accepts basic HTML — bold, italics, links, lists, headings, and line breaks. Leave blank to keep the call-out from linking anywhere.
- <%= f.text_area :description, rows: 4, - placeholder: "e.g.Enter through the north lot…
", - class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm font-mono" %> -Optional. Shows the resource below the content on the call-out's page (PDF first-page preview, etc.), with a download button when the resource has a downloadable file.
- <%= f.collection_select :resource_id, Resource.order(:title), :id, :title, - { include_blank: "None" }, - class: "w-full rounded border-gray-300 shadow-sm px-2 py-1 text-sm" %> -Optional. Shows each resource below the content on the call-out's page (PDF first-page preview, etc.), with a download button when the resource has a downloadable file.
+ +<%= @event.title %>
<%= @callout.subtitle %>
@@ -14,9 +13,9 @@Details for this callout.
" } callout_type { "reference" } payment_access_gated { false } + hidden { false } # position is assigned by the positioning gem on save (appended within the event) + # Convenience: `create(:registration_ticket_callout, resource:)` links one + # resource through the join, and `resources: [a, b]` links several. + transient do + resource { nil } + resources { [] } + end + + after(:create) do |callout, evaluator| + Array(evaluator.resource).each { |r| callout.resources << r } + evaluator.resources.each { |r| callout.resources << r } + end + trait :action do callout_type { "action" } end @@ -15,5 +28,14 @@ trait :payment_access_gated do payment_access_gated { true } end + + trait :hidden do + hidden { true } + end + + trait :magic do + magic_key { "faq" } + title { "Frequently asked questions" } + end end end diff --git a/spec/models/registration_ticket_callout_spec.rb b/spec/models/registration_ticket_callout_spec.rb index 7482db0f24..6d50769252 100644 --- a/spec/models/registration_ticket_callout_spec.rb +++ b/spec/models/registration_ticket_callout_spec.rb @@ -27,6 +27,88 @@ callout.color_class = "chartreuse" expect(callout).not_to be_valid end + + it "rejects an unknown magic_key" do + callout.magic_key = "bogus" + expect(callout).not_to be_valid + expect(callout.errors[:magic_key]).to be_present + end + + it "allows only one callout per magic_key within an event" do + event = create(:event) + create(:registration_ticket_callout, event:, magic_key: "faq") + duplicate = build(:registration_ticket_callout, event:, magic_key: "faq") + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:magic_key]).to be_present + end + + it "allows many custom callouts with a nil magic_key" do + event = create(:event) + create(:registration_ticket_callout, event:, magic_key: nil) + expect(build(:registration_ticket_callout, event:, magic_key: nil)).to be_valid + end + end + + describe "scopes and predicates" do + it "partitions magic and custom callouts and reports #magic?" do + event = create(:event) + magic = create(:registration_ticket_callout, event:, magic_key: "faq") + custom = create(:registration_ticket_callout, event:, magic_key: nil) + + expect(event.registration_ticket_callouts.magic).to eq([ magic ]) + expect(event.registration_ticket_callouts.custom).to eq([ custom ]) + expect(magic.magic?).to be(true) + expect(custom.magic?).to be(false) + end + + it "#visible excludes hidden callouts" do + event = create(:event) + shown = create(:registration_ticket_callout, event:) + create(:registration_ticket_callout, :hidden, event:) + + expect(event.registration_ticket_callouts.visible).to eq([ shown ]) + end + end + + describe "#published (inverse of hidden)" do + it "reads and writes the hidden flag inverted" do + callout = build(:registration_ticket_callout, hidden: false) + expect(callout.published).to be(true) + + callout.published = "0" + expect(callout.hidden).to be(true) + expect(callout.published).to be(false) + + callout.published = "1" + expect(callout.hidden).to be(false) + end + end + + describe "#dripping?" do + it "is true only while display_from is in the future" do + callout.display_from = 1.day.from_now + expect(callout.dripping?).to be(true) + + callout.display_from = 1.day.ago + expect(callout.dripping?).to be(false) + + callout.display_from = nil + expect(callout.dripping?).to be(false) + end + end + + describe "linked resources" do + it "links many resources in order and removes them with the callout" do + callout = create(:registration_ticket_callout) + a = create(:resource) + b = create(:resource) + callout.resources << a + callout.resources << b + + expect(callout.reload.resources).to eq([ a, b ]) + expect { callout.destroy }.to change(RegistrationTicketCalloutResource, :count).by(-2) + end end describe "positioning" do diff --git a/spec/requests/events/callouts_spec.rb b/spec/requests/events/callouts_spec.rb index 28962d9968..f0545c9497 100644 --- a/spec/requests/events/callouts_spec.rb +++ b/spec/requests/events/callouts_spec.rb @@ -64,6 +64,38 @@ expect(response).to redirect_to(registration_ticket_path(registration.slug)) end end + + context "when the Forms callout is materialized" do + let(:event) { create(:event, cost_cents: 1099) } + let!(:w9) { create(:resource, title: "W-9") } + + it "links the Forms callout's resources, and drops the W-9 when removed" do + DefaultTicketCallouts.seed(event) + + get registration_forms_path(registration.slug) + expect(response.body).to include(registration_resource_path(registration.slug, w9, return_to: "forms")) + + event.registration_ticket_callouts.find_by(magic_key: "forms").resources.destroy_all + get registration_forms_path(registration.slug) + expect(response.body).not_to include(registration_resource_path(registration.slug, w9, return_to: "forms")) + end + end + end + + describe "GET /registration/:slug/videoconference" do + let(:event) { create(:event, videoconference_url: "https://example.com/zoom") } + + it "renders resources linked to the built-in callout below the content" do + resource = create(:resource) + create(:downloadable_asset, owner: resource) + create(:registration_ticket_callout, event:, magic_key: "videoconference", + title: "Videoconference", resources: [ resource ]) + + get registration_videoconference_path(registration.slug) + + expect(response).to have_http_status(:success) + expect(response.body).to include(rails_blob_path(resource.downloadable_asset.file, only_path: true)) + end end describe "GET /registration/:slug/ce" do diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index 7a81040211..42ae4e42ca 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -54,6 +54,23 @@ expect(response).to have_http_status(:not_found) end + it "redirects a hidden callout's page back to the event" do + callout = create(:registration_ticket_callout, :hidden, event:, description: "Draft.
") + + get event_registration_ticket_callout_path(event, callout) + + expect(response).to redirect_to(event_path(event)) + end + + it "redirects a not-yet-dripped callout's page back to the event" do + callout = create(:registration_ticket_callout, event:, description: "Later.
", + display_from: 1.day.from_now) + + get event_registration_ticket_callout_path(event, callout) + + expect(response).to redirect_to(event_path(event)) + end + context "when linked to a resource with a downloadable file" do let(:resource) { create(:resource) } let(:callout) do @@ -105,12 +122,12 @@ } } - ordered = event.registration_ticket_callouts.reload.ordered + ordered = event.registration_ticket_callouts.custom.reload.ordered expect(ordered.map(&:title)).to eq(%w[First Second Third]) expect(ordered.map(&:position)).to eq([ 1, 2, 3 ]) end - it "links a callout to a resource through nested attributes" do + it "links a callout to resources through nested attributes" do resource = create(:resource) patch event_path(event), params: { @@ -119,13 +136,30 @@ start_date: event.start_date, end_date: event.end_date, registration_ticket_callouts_attributes: { - "0" => { title: "Workbook", callout_type: "reference", resource_id: resource.id } + "0" => { title: "Workbook", callout_type: "reference", + registration_ticket_callout_resources_attributes: { "0" => { resource_id: resource.id } } } } } } callout = event.registration_ticket_callouts.reload.find_by(title: "Workbook") - expect(callout.resource).to eq(resource) + expect(callout.resources).to eq([ resource ]) + end + + it "saves a callout's drip display date through nested attributes" do + patch event_path(event), params: { + event: { + title: event.title, + start_date: event.start_date, + end_date: event.end_date, + registration_ticket_callouts_attributes: { + "0" => { title: "Handbook", callout_type: "reference", display_from: "2026-08-01" } + } + } + } + + callout = event.registration_ticket_callouts.reload.find_by(title: "Handbook") + expect(callout.display_from.to_date).to eq(Date.new(2026, 8, 1)) end end @@ -152,4 +186,95 @@ expect(callout.reload.position).to eq(2) end end + + describe "restoring a built-in through the event form" do + before { sign_in admin } + + it "resets a callout flagged reset_to_default on the main save" do + training = create(:event, :publicly_visible, facilitator_training: true) + callout = create(:registration_ticket_callout, event: training, magic_key: "faq", title: "Edited", hidden: true) + + patch event_path(training), params: { + event: { + title: training.title, start_date: training.start_date, end_date: training.end_date, + registration_ticket_callouts_attributes: { + "0" => { id: callout.id, title: "Still edited", reset_to_default: "1" } + } + } + } + + expect(callout.reload.title).to eq("Frequently asked questions") + expect(callout.hidden).to be(false) + end + end + + describe "seeding built-in callouts on save" do + before { sign_in admin } + + it "materializes the built-in callouts when an event is updated" do + patch event_path(event), params: { + event: { title: event.title, start_date: event.start_date, end_date: event.end_date } + } + + expect(event.registration_ticket_callouts.magic.pluck(:magic_key)).to contain_exactly( + "payment", "certificate", "scholarship", "ce_hours", "event_details", + "videoconference", "forms", "handouts", "faq" + ) + end + end + + describe "the event editor" do + before { sign_in admin } + + it "renders a materialized content callout as an editable field with a restore checkbox" do + create(:registration_ticket_callout, event:, magic_key: "faq", + title: "Frequently asked questions") + + get edit_event_path(event) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][title]\"") + expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][reset_to_default]\"") + end + + it "renders a behavioral magic callout with the same editable fields as a custom one" do + create(:registration_ticket_callout, event:, magic_key: "certificate", + title: "Certificate of completion") + + get edit_event_path(event) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][title]\"") + expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][description]\"") + expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][published]\"") + expect(response.body).to include("Add resource") # every built-in can link resources now + expect(response.body).to include("name=\"event[registration_ticket_callouts_attributes][0][reset_to_default]\"") + end + + it "shows the restore checkbox only once the built-in has been customized" do + # Seed unedited built-ins, then load the editor. + DefaultTicketCallouts.seed(event) + faq = event.registration_ticket_callouts.find_by(magic_key: "faq") + + get edit_event_path(event) + expect(response.body).to include("Matches default") + expect(response.body).not_to include("reset_to_default") + + faq.update!(title: "Our FAQ") + get edit_event_path(event) + expect(response.body).to include("reset_to_default") + end + + it "gives the Forms card an add-another linked-resource picker" do + resource = create(:resource, title: "W-9") + create(:registration_ticket_callout, event:, magic_key: "forms", title: "Forms", resources: [ resource ]) + + get edit_event_path(event) + + expect(response).to have_http_status(:ok) + # One dropdown per linked resource, plus an "Add resource" link (cocoon). + expect(response.body).to match(/registration_ticket_callout_resources_attributes\]\[\d+\]\[resource_id\]/) + expect(response.body).to include("Add resource") + end + end end diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 8ca3e92ddf..13a03057bd 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -263,20 +263,12 @@ 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 fields within the callouts section" do + it "renders the built-in 'Before you attend' 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") - expect(response.body).to include("event[event_details_label]") - expect(response.body).to include("event[event_details]") - end - - it "renders the built-in 'CE hours' card fields within the callouts section" do - get edit_event_path(event) - expect(response.body).to include("Registration ticket callouts") - expect(response.body).to include("CE hours") - expect(response.body).to include("event[ce_hours_details_label]") - expect(response.body).to include("event[ce_hours_details]") + # Its text now lives on the callout row, not the event columns. + expect(response.body).not_to include("event[event_details_label]") end it "previews the app-controlled built-in callouts (greyed, non-editable)" do diff --git a/spec/services/default_ticket_callouts_spec.rb b/spec/services/default_ticket_callouts_spec.rb new file mode 100644 index 0000000000..65a9430629 --- /dev/null +++ b/spec/services/default_ticket_callouts_spec.rb @@ -0,0 +1,231 @@ +require "rails_helper" + +RSpec.describe DefaultTicketCallouts do + describe "#seed" do + it "materializes all nine built-in callouts for every event" do + event = create(:event, cost_cents: 0) # free, no scholarship form, no VC link + + described_class.seed(event) + + keys = event.registration_ticket_callouts.magic.pluck(:magic_key) + expect(keys).to contain_exactly( + "payment", "certificate", "scholarship", "ce_hours", "event_details", + "videoconference", "forms", "handouts", "faq" + ) + end + + 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") + event.event_forms.create!(form:, role: "scholarship") + + described_class.seed(event) + + expect(event.registration_ticket_callouts.ordered.map(&:magic_key)).to eq( + %w[payment certificate scholarship ce_hours event_details videoconference forms handouts faq] + ) + end + + it "seeds the Videoconference card with a drip date a week before start" do + event = create(:event, start_date: Date.new(2026, 9, 10)) + described_class.seed(event) + + vc = event.registration_ticket_callouts.find_by(magic_key: "videoconference") + expect(vc).to be_present + expect(vc.display_from.to_date).to eq(Date.new(2026, 9, 3)) + expect(vc.hidden).to be(true) # non-training: hidden by default + end + + it "hides all callouts by default on a non-training event, shows all on a facilitator training" do + form = create(:form) + non_training = create(:event, cost_cents: 0) + non_training.event_forms.create!(form:, role: "scholarship") + training = create(:event, :publicly_visible, facilitator_training: true, cost_cents: 100, + videoconference_url: "https://example.com/vc") + training.event_forms.create!(form:, role: "scholarship") + + described_class.seed(non_training) + described_class.seed(training) + + non_training.registration_ticket_callouts.magic.each do |callout| + expect(callout.hidden).to be(true), "expected #{callout.magic_key} hidden on non-training" + end + training.registration_ticket_callouts.magic.each do |callout| + expect(callout.hidden).to be(false), "expected #{callout.magic_key} visible on training" + end + end + + it "defaults Certificate off for a non-training event and on for a facilitator training" do + non_training = create(:event, facilitator_training: false) + training = create(:event, facilitator_training: true) + + described_class.seed(non_training) + described_class.seed(training) + + expect(non_training.registration_ticket_callouts.find_by(magic_key: "certificate").hidden).to be(true) + expect(training.registration_ticket_callouts.find_by(magic_key: "certificate").hidden).to be(false) + end + + it "links the W-9 to the Forms card as a removable resource on paid events" do + w9 = create(:resource, title: "W-9") + event = create(:event) # paid by factory, so Forms seeds + + described_class.seed(event) + + forms = event.registration_ticket_callouts.find_by(magic_key: "forms") + expect(forms.resources).to eq([ w9 ]) + end + + it "omits the W-9 from the Forms card on a free (training) event" do + create(:resource, title: "W-9") + event = create(:event, facilitator_training: true, cost_cents: 0) + + described_class.seed(event) + + forms = event.registration_ticket_callouts.find_by(magic_key: "forms") + expect(forms).to be_present + expect(forms.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.
") + + described_class.seed(event) + + ce = event.registration_ticket_callouts.find_by(magic_key: "ce_hours") + details = event.registration_ticket_callouts.find_by(magic_key: "event_details") + 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. + expect(described_class.customized?(ce)).to be(false) + end + + it "reports whether a materialized callout has been customized" do + event = create(:event, facilitator_training: true) + described_class.seed(event) + faq = event.registration_ticket_callouts.find_by(magic_key: "faq") + + expect(described_class.customized?(faq)).to be(false) + + faq.update!(title: "Our questions") + expect(described_class.customized?(faq)).to be(true) + + described_class.reset(faq) + expect(described_class.customized?(faq.reload)).to be(false) + end + + it "treats a changed drip display date as customized" do + event = create(:event, facilitator_training: true) + described_class.seed(event) + faq = event.registration_ticket_callouts.find_by(magic_key: "faq") + + expect(described_class.customized?(faq)).to be(false) + + faq.update!(display_from: Date.new(2026, 8, 1)) + expect(described_class.customized?(faq)).to be(true) + + described_class.reset(faq) + expect(described_class.customized?(faq.reload)).to be(false) + end + + it "seeds the FAQ card with the default training content" do + event = create(:event) + + described_class.seed(event) + + faq = event.registration_ticket_callouts.find_by(magic_key: "faq") + expect(faq.description).to include("Who is this training designed for?") + expect(faq.callout_type).to eq("reference") + end + + it "links the handout worksheet resources in display order" do + first = create(:resource, title: "2-Day AWBW Facilitator Training Worksheets & Handouts") + second = create(:resource, title: "AWBW Training Workshop Worksheets") + event = create(:event) + + described_class.seed(event) + + handouts = event.registration_ticket_callouts.find_by(magic_key: "handouts") + expect(handouts.resources).to eq([ first, second ]) + end + + it "shows Handouts and FAQ by default on a facilitator training" do + event = create(:event, facilitator_training: true) + + described_class.seed(event) + + expect(event.registration_ticket_callouts.where(magic_key: %w[handouts faq]).pluck(:hidden)).to all(be(false)) + end + + it "hides Handouts and FAQ by default on a non-training event" do + event = create(:event, facilitator_training: false) + + described_class.seed(event) + + expect(event.registration_ticket_callouts.where(magic_key: %w[handouts faq]).pluck(:hidden)).to all(be(true)) + end + + it "is idempotent and never clobbers an existing magic callout" do + event = create(:event, facilitator_training: true) + described_class.seed(event) + faq = event.registration_ticket_callouts.find_by(magic_key: "faq") + faq.update!(description: "Custom answer.
", hidden: true) + + expect { described_class.seed(event) }.not_to change { event.registration_ticket_callouts.count } + expect(faq.reload.description).to eq("Custom answer.
") + expect(faq.hidden).to be(true) + end + + it "appends the magic callouts after existing custom ones" do + event = create(:event) + custom = create(:registration_ticket_callout, event:, title: "Parking") + + described_class.seed(event) + + expect(event.registration_ticket_callouts.ordered.first).to eq(custom) + expect(event.registration_ticket_callouts.ordered.map(&:magic_key).compact).to eq( + %w[payment certificate scholarship ce_hours event_details videoconference forms handouts faq] + ) + end + end + + describe ".reset" do + it "restores an edited magic callout's content, resources, and visibility to default" do + resource = create(:resource, title: "AWBW Training Workshop Worksheets") + event = create(:event, facilitator_training: true) + described_class.seed(event) + faq = event.registration_ticket_callouts.find_by(magic_key: "faq") + faq.update!(title: "Custom", description: "Edited
", hidden: true) + faq.resources << resource + + described_class.reset(faq) + + expect(faq.reload.title).to eq("Frequently asked questions") + expect(faq.description).to include("Who is this training designed for?") + expect(faq.hidden).to be(false) + expect(faq.resources).to be_empty + end + + it "keeps the callout's position when restoring" do + event = create(:event, facilitator_training: true) + described_class.seed(event) + faq = event.registration_ticket_callouts.find_by(magic_key: "faq") + original_position = faq.position + + described_class.reset(faq) + + expect(faq.reload.position).to eq(original_position) + end + + it "leaves a custom callout untouched" do + callout = create(:registration_ticket_callout, title: "Parking", magic_key: nil) + + expect { described_class.reset(callout) }.not_to change { callout.reload.title } + end + end +end diff --git a/spec/services/magic_ticket_callouts_spec.rb b/spec/services/magic_ticket_callouts_spec.rb index 7acb7067fe..9cfedbbbb7 100644 --- a/spec/services/magic_ticket_callouts_spec.rb +++ b/spec/services/magic_ticket_callouts_spec.rb @@ -29,6 +29,16 @@ def card(reg, title) expect(card_titles(registration)).to include("Handouts", "Frequently asked questions") end + it "skips a built-in card the event has materialized (it renders from the row instead)" do + event.update!(facilitator_training: true) + create(:registration_ticket_callout, event:, magic_key: "faq", title: "Frequently asked questions") + + # No duplicate FAQ from the code path; Handouts (not materialized) still renders here. + titles = card_titles(registration) + expect(titles.count("Frequently asked questions")).to eq(0) + expect(titles).to include("Handouts") + end + it "shows the Forms card for facilitator trainings and paid events, but not free non-trainings" do expect(card_titles(registration)).to include("Forms") @@ -105,6 +115,15 @@ def card(reg, title) scholarship_card = card(registration, "Scholarship") expect(scholarship_card.subtitle).to eq("Your scholarship request status") expect(scholarship_card.badge).to be_nil + expect(scholarship_card.theme).to eq(DomainTheme.swatch(DomainTheme.color_for(:scholarships))) + end + + it "turns the scholarship card amber while award tasks are outstanding" do + registration.update!(scholarship_requested: true) + scholarship = create(:scholarship, recipient: registration.registrant, tasks_completed: false) + create(:allocation, source: scholarship, allocatable: registration, amount: 1000) + + expect(card(registration, "Scholarship").theme).to eq(DomainTheme.swatch("amber")) end it "flags an awarded scholarship with outstanding tasks in an amber chip" do @@ -146,4 +165,36 @@ def card(reg, title) ]) end end + + describe "#card_for" do + it "uses the row's editable presentation (title/subtitle/colour) but the app's live badge/link" do + # Forms isn't app-coloured, so the row's colour is honoured. + callout = create(:registration_ticket_callout, event:, magic_key: "forms", + title: "Your documents", subtitle: "Downloads", color_class: "green") + + card = described_class.new(registration).card_for(callout) + expect(card.title).to eq("Your documents") # from the row + expect(card.subtitle).to eq("Downloads") # from the row + expect(card.theme).to eq(DomainTheme.swatch("green")) # from the row + end + + it "keeps Payment's live-status colour, overriding the selected colour" do + event.update!(cost_cents: 5_000) + callout = create(:registration_ticket_callout, event:, magic_key: "payment", + title: "Pay your balance", color_class: "green") + + card = described_class.new(registration).card_for(callout) + expect(card.title).to eq("Pay your balance") # row still owns text + expect(card.theme).to eq(DomainTheme.swatch("orange")) # app colour (balance due), not green + expect(card.badge).to end_with("due") + end + + it "returns nil when the card shouldn't show for this registration" do + callout = create(:registration_ticket_callout, event:, magic_key: "certificate", + title: "Certificate of completion") + + # Certificate isn't unlocked (event not ended, not attended). + expect(described_class.new(registration).card_for(callout)).to be_nil + end + end end