diff --git a/AGENTS.md b/AGENTS.md index 76f6cb9d8..a3ea19ff7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -210,6 +210,7 @@ end - `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 +- `SampleTicketRegistration` — Builds the **unsaved, data-free** `EventRegistration` ("Sample Person") that the sample ticket and its admin-only callout-page previews render from; nothing is ever persisted, so the preview can't read from or write to a real registrant or leak into counts/revenue/rosters/reminders. `all_options:` mirrors the ticket's "Show all options" toggle (turns on scholarship/CE/W-9 so those cards and preview pages render). Shared by `EventsController#sample_ticket` and `Events::CalloutsController`'s sample mode (the `sample` param → admin-authed in-memory previews of the behavioral built-in pages, linked from the sample ticket via `EventHelper#sample_callout_path`) ### Affiliations diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index e421c06d9..63ab39883 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -4,7 +4,9 @@ module Events # Each is reachable by the registration slug — the slug is the authorization, # so no login is required (mirrors the public ticket/invoice pages). class CalloutsController < ApplicationController - skip_before_action :authenticate_user! + # Real registrant pages are public (the slug is the authorization); the + # sample-ticket previews instead require an admin (authorized in #authorize_callout). + skip_before_action :authenticate_user!, unless: :sample_preview? before_action :set_event_registration before_action :authorize_callout before_action :set_event @@ -12,6 +14,8 @@ class CalloutsController < ApplicationController # above the app-controlled content, plus any resources linked to the row. before_action :set_builtin_content, only: %i[ payment scholarship certificate videoconference ] + helper_method :sample_preview? + # The single-resource page previews a PDF in an (object-src). The # global policy blocks / with object_src :none; relax to :self # only here, matching ResourcesController#show. The blob streams same-origin @@ -34,13 +38,19 @@ class CalloutsController < ApplicationController # the linked documents (the W-9 from the payment callout's resources, and the # dynamic invoice/receipt) for paid events. def payment - @allocations = @event_registration.allocations.includes(:source).order(:created_at) - @document_cards = payment_document_cards + # The sample preview has no ledger or documents to link (its sentinel slug + # wouldn't resolve), so it just shows the empty structure. + @allocations = sample_preview? ? [] : @event_registration.allocations.includes(:source).order(:created_at) + @document_cards = sample_preview? ? [] : payment_document_cards end # Certificate of completion, rendered like the invoice. Only reachable once # the certificate is unlocked. def certificate + # The sample preview always shows the template; a real registrant only sees + # it once the certificate is unlocked. + return if sample_preview? + unless @event_registration.certificate_available? redirect_to registration_ticket_path(@event_registration.slug) end @@ -188,16 +198,32 @@ def builtin_published?(builtin_key) @event.registration_ticket_callouts.exists?(builtin_key: builtin_key, hidden: false) end + # Admin-only preview from the sample ticket. Renders these pages for an + # unsaved, data-free sample registration instead of a real one looked up by + # slug, so nothing is ever read from or written to a real registrant. + def sample_preview? + params[:sample].present? + end + def set_event_registration - @event_registration = EventRegistration.find_by!(slug: params[:slug]) + if sample_preview? + @event = Event.find(params[:id]) + @event_registration = SampleTicketRegistration.new(@event, all_options: true).registration + else + @event_registration = EventRegistration.find_by!(slug: params[:slug]) + end end def authorize_callout - authorize! @event_registration, to: :show_public? + if sample_preview? + authorize! @event, to: :dashboard? + else + authorize! @event_registration, to: :show_public? + end end def set_event - @event = @event_registration.event + @event ||= @event_registration.event end # The editable intro and linked resources for a built-in page, from the diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 1424cf656..c6db2a6ff 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -67,20 +67,18 @@ def dashboard def sample_ticket authorize! @event, to: :dashboard? + # Materialize any missing built-in callouts (idempotent, like #edit) so the + # preview renders purely from the event's materialized rows — the built-ins + # are real editable rows once seeded, so there's no code-defined fallback to + # fall back to here. + BuiltinCallouts.seed(@event) + @show_all_options = params[:options] == "all" - registrant = Person.new(first_name: "Sample", last_name: "Registrant") - @event_registration = @event.event_registrations.new( - registrant: registrant, - slug: "sample", - status: "registered", - intends_to_pay: true, - w9_requested: @show_all_options, - invoice_requested: @show_all_options, - scholarship_requested: @show_all_options, - shoutout: @show_all_options, - created_at: Time.current - ) - build_sample_ce_registration if @show_all_options + # Always an unsaved, data-free sample so the preview can never read from or + # write to a real registrant. Its behavioral built-in cards link to the + # admin-only sample callout previews (see Events::CalloutsController), which + # render the same in-memory sample — nothing is ever persisted. + @event_registration = SampleTicketRegistration.new(@event, all_options: @show_all_options).registration end def background @@ -507,18 +505,6 @@ def copy_registration_form private - # Build (unsaved) a CE registration on the sample ticket so the "Show all - # options" preview renders a populated CE card. Mirrors a complete, paid-looking - # CE record without touching the database. - def build_sample_ce_registration - license = ProfessionalLicense.new(person: @event_registration.registrant, number: "SAMPLE-12345") - @event_registration.continuing_education_registrations.build( - professional_license: license, - hours: @event.ce_hours_offered || 6, - cost_cents: @event.ce_hours_cost_cents || 15_000 - ) - end - # The registrations the admin checked on the recipient picker, narrowed to those # we can actually email. Shared by the confirm interstitial and the send action # so both operate on exactly the same set. diff --git a/app/helpers/event_helper.rb b/app/helpers/event_helper.rb index 6e61efefa..5537f3060 100644 --- a/app/helpers/event_helper.rb +++ b/app/helpers/event_helper.rb @@ -1,4 +1,17 @@ module EventHelper + # The admin-only sample-ticket preview path for a behavioral built-in callout's + # per-registration page, keyed off its builtin_key. Returns nil for a callout + # with no previewable page, so the sample ticket leaves that card non-navigating. + def sample_callout_path(event, callout) + case callout.builtin_key + when "payment" then sample_payment_event_path(event) + when "certificate" then sample_certificate_event_path(event) + when "scholarship" then sample_scholarship_event_path(event) + when "ce_hours" then sample_ce_event_path(event) + when "videoconference" then sample_videoconference_event_path(event) + end + end + # The "please specify" placeholder for an option label, or nil when the option # does not reveal a free-text box. Canonical config lives on FormField (shared # with answer validation). diff --git a/app/services/builtin_callout_cards.rb b/app/services/builtin_callout_cards.rb index 83c155b2c..1a3b47c63 100644 --- a/app/services/builtin_callout_cards.rb +++ b/app/services/builtin_callout_cards.rb @@ -92,13 +92,12 @@ def self.config_gap(event, builtin_key) end end + # `preview: true` is the sample ticket: it bypasses the config gaps so an admin + # can preview (and click through) a published built-in card even before the + # event carries the config it depends on (a cost, a scholarship form, CE hours). 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 @@ -135,6 +134,13 @@ def card_for(callout) attr_reader :registration, :event + # Whether this event lacks the config the built-in card depends on, so it can't + # reach a real ticket. The sample-ticket preview ignores the gap so an admin can + # still see and click through the card while finishing the event's setup. + def config_gap?(builtin_key) + !@preview && self.class.config_gap(event, builtin_key).present? + end + # A built-in card the event has materialized into an editable row renders from # that row, not from #cards, so we skip it here to avoid double-rendering. def materialized?(builtin_key) @@ -146,7 +152,7 @@ def materialized?(builtin_key) # 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. def payment_card - return if self.class.config_gap(event, "payment") + return if config_gap?("payment") due = registration.remaining_cost.to_i.positive? Card.new(icon_class: "fa-solid fa-credit-card", color: due ? "orange" : "blue", title: due ? "Make your payment" : "Payment", @@ -173,7 +179,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 !@preview && self.class.config_gap(event, "scholarship") + return if config_gap?("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 @@ -210,7 +216,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 !@preview && self.class.config_gap(event, "ce_hours") + return if config_gap?("ce_hours") return unless registration.ce_registered? complete = registration.ce_license_provided? # An outstanding CE balance turns the card orange (an action card), matching @@ -272,7 +278,7 @@ def ce_deadline_text(deadline) # Shown only when the event has a videoconference URL set. def videoconference_card - return if self.class.config_gap(event, "videoconference") + return if config_gap?("videoconference") Card.new(icon_class: "fa-solid fa-video", color: "blue", title: "Videoconference", subtitle: "Join link and how to add it to your calendar", diff --git a/app/services/sample_ticket_registration.rb b/app/services/sample_ticket_registration.rb new file mode 100644 index 000000000..89a3b6726 --- /dev/null +++ b/app/services/sample_ticket_registration.rb @@ -0,0 +1,42 @@ +# Builds the unsaved, data-free registration the sample ticket (and its +# admin-only callout page previews) render from. Nothing is ever persisted, so +# the preview can never read from or write to a real registrant, nor leak into +# counts, revenue, rosters, or reminders. Shared by EventsController#sample_ticket +# and Events::CalloutsController's sample mode so both preview the same person. +class SampleTicketRegistration + # `all_options` mirrors the ticket's "Show all options" toggle: it turns on the + # scholarship/CE/W-9 flags so those callout cards (and their preview pages) + # render as they would for a registrant using every option. + def initialize(event, all_options: false) + @event = event + @all_options = all_options + end + + def registration + registrant = Person.new(first_name: "Sample", last_name: "Person") + registration = @event.event_registrations.new( + registrant: registrant, + slug: "sample", + status: "registered", + intends_to_pay: true, + w9_requested: @all_options, + invoice_requested: @all_options, + scholarship_requested: @all_options, + shoutout: @all_options, + created_at: Time.current + ) + build_ce_registration(registration) if @all_options + registration + end + + private + + def build_ce_registration(registration) + license = ProfessionalLicense.new(person: registration.registrant, number: "SAMPLE-12345") + registration.continuing_education_registrations.build( + professional_license: license, + hours: @event.ce_hours_offered || 6, + cost_cents: @event.ce_hours_cost_cents || 15_000 + ) + end +end diff --git a/app/views/event_registrations/_ticket.html.erb b/app/views/event_registrations/_ticket.html.erb index a4cbd948e..21e2b7b71 100644 --- a/app/views/event_registrations/_ticket.html.erb +++ b/app/views/event_registrations/_ticket.html.erb @@ -1,4 +1,5 @@ <% preview = local_assigns.fetch(:preview, false) %> +<% show_all = local_assigns.fetch(:show_all, false) %>
@@ -103,31 +104,52 @@ <% builtin_cards = BuiltinCalloutCards.new(event_registration, preview: preview) %> - <% builtin_cards.cards.each do |card| %> - <%= render "event_registrations/callout_card", callout: card, linked: !preview %> + the event hasn't materialized (the fallback for old, unseeded events). + Each knows its own visibility rule. The sample-ticket preview seeds the + event first, so it skips this and renders purely from the rows below. --> + <% unless preview %> + <% builtin_cards.cards.each do |card| %> + <%= render "event_registrations/callout_card", callout: card, linked: true %> + <% end %> <% end %> + editable rows, in the one admin-ordered list. A behavioral built-in row + (payment, certificate, …) renders the app's live card; content/custom rows + render their own copy and link to their detail page. On a real ticket only + published, payment-eligible callouts show; a drip date only withholds the + page content, not the card. The preview shows the same published set by + default; "Show all options" additionally reveals unpublished rows so admins + can preview every card. --> <% payment_access = event_registration.payment_access_granted? || @checkout_payment_status == "paid" %> - <% event_registration.event.registration_ticket_callouts.visible.each do |callout| %> - <% next if callout.payment_access_gated && !payment_access %> + <% callouts = preview && show_all ? event_registration.event.registration_ticket_callouts : event_registration.event.registration_ticket_callouts.visible %> + <% callouts.each do |callout| %> + <% next if callout.payment_access_gated && !payment_access && !(preview && show_all) %> <% if callout.behavioral_builtin? %> <% card = builtin_cards.card_for(callout) %> <% if card %> - <%= render "event_registrations/callout_card", callout: card, linked: !preview %> + <%# Behavioral cards link to per-registration pages. On a real ticket that's + the registrant's live page; in the preview it's the admin-only in-memory + sample of that page (nil for a card with no previewable page). %> + <% sample_href = sample_callout_path(event_registration.event, callout) if preview %> + <%= render "event_registrations/callout_card", callout: card, + href: preview ? sample_href : card.href, + linked: preview ? sample_href.present? : true %> + <% elsif preview && show_all %> + + <% sample_href = sample_callout_path(event_registration.event, callout) %> + <%= render "event_registrations/callout_card", callout: callout, + href: sample_href, linked: sample_href.present? %> <% end %> <% else %> <%= render "event_registrations/callout_card", callout: callout, - href: event_registration_ticket_callout_path(event_registration.event, callout, reg: event_registration.slug), - linked: !preview %> + href: preview ? + event_registration_ticket_callout_path(event_registration.event, callout, return_to: "sample_ticket") : + event_registration_ticket_callout_path(event_registration.event, callout, reg: event_registration.slug), + linked: preview ? callout.page_content? : true %> <% end %> <% end %> diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index 5c6975b3f..203b8e53d 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -569,6 +569,21 @@ 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.

+ <% if @event.persisted? %> + <%# Opens the sample ticket in a new tab so the admin keeps their place in + the editor. It reflects saved callouts, not unsaved edits in this form. %> +
+ <%= link_to sample_ticket_event_path(@event, return_to: "edit_callouts"), + target: "_blank", rel: "noopener", + class: "inline-flex items-center gap-2 rounded-md border border-purple-200 bg-purple-50 px-3 py-1.5 text-sm font-medium text-purple-700 hover:bg-purple-100" do %> + + Preview sample ticket + + <% end %> +

Opens in a new tab, showing published callouts (use “Show all options” there to preview unpublished ones). Reflects saved callouts — save your edits first.

+
+ <% end %> + <%# Built-in cards, shown above custom callouts in the order they appear on the ticket — greyed-out previews of cards the app controls, so the full diff --git a/app/views/events/callouts/_callout_page.html.erb b/app/views/events/callouts/_callout_page.html.erb index 1261743ae..983defbb5 100644 --- a/app/views/events/callouts/_callout_page.html.erb +++ b/app/views/events/callouts/_callout_page.html.erb @@ -5,7 +5,7 @@ callers without a registration can override it with a `back_path` local, or pass `back_path: nil` to drop the eyebrow. Its label defaults to "Back to ticket"; pass a `back_label` local to point it at a different origin. %> -<% back_path = local_assigns.fetch(:back_path) { registration_ticket_path(@event_registration.slug) } %> +<% back_path = local_assigns.fetch(:back_path) { sample_preview? ? sample_ticket_event_path(@event) : registration_ticket_path(@event_registration.slug) } %> <% back_label = local_assigns.fetch(:back_label, "Back to ticket") %> <%# Most callout pages are a narrow card; the single-resource page passes a wider value so a multi-page PDF preview has room for the browser's page sidebar. %> diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 9ba558ed8..a73ccf281 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -32,7 +32,7 @@ Admin-only jump to the management surface for this CE registration. Hidden from registrants; opens in a new tab so the registrant view is kept. %> - <% if ce_registration && allowed_to?(:edit?, ce_registration) %> + <% if ce_registration && allowed_to?(:edit?, ce_registration) && !sample_preview? %>
<%= link_to edit_continuing_education_registration_path(ce_registration), 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 %> @@ -86,10 +86,15 @@

Payment due by <%= @event.ce_payment_due_deadline.to_fs(:long) %>

<% end %>
- <%= button_to "Pay with Credit Card", - registration_ce_pay_path(@event_registration.slug), - data: { turbo: false }, - class: "btn btn-accent px-6 py-2 text-sm uppercase font-telefon" %> + <% if sample_preview? %> + + <% else %> + <%= button_to "Pay with Credit Card", + registration_ce_pay_path(@event_registration.slug), + data: { turbo: false }, + class: "btn btn-accent px-6 py-2 text-sm uppercase font-telefon" %> + <% end %>
<% end %> @@ -114,7 +119,7 @@

Your professional license

- <% if license_on_file && !editing_license && !license_locked %> + <% if license_on_file && !editing_license && !license_locked && !sample_preview? %> <%= link_to registration_ce_path(@event_registration.slug, editing: "license", return_to: params[:return_to].presence), class: "shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-600 hover:bg-gray-50" do %> diff --git a/app/views/events/callouts/payment.html.erb b/app/views/events/callouts/payment.html.erb index e1fb3a008..7982d6cd4 100644 --- a/app/views/events/callouts/payment.html.erb +++ b/app/views/events/callouts/payment.html.erb @@ -29,10 +29,15 @@ <% if @event_registration.remaining_cost.positive? %>
- <%= button_to "Pay with Credit Card", - registration_pay_path(@event_registration.slug), - data: { turbo: false }, - class: "btn btn-accent px-6 py-2 text-sm uppercase font-telefon" %> + <% if sample_preview? %> + + <% else %> + <%= button_to "Pay with Credit Card", + registration_pay_path(@event_registration.slug), + data: { turbo: false }, + class: "btn btn-accent px-6 py-2 text-sm uppercase font-telefon" %> + <% end %>
diff --git a/app/views/events/sample_ticket.html.erb b/app/views/events/sample_ticket.html.erb index 308c4475f..740d91f4c 100644 --- a/app/views/events/sample_ticket.html.erb +++ b/app/views/events/sample_ticket.html.erb @@ -6,6 +6,9 @@ when "registrants" %> <% back_label = "← Registrants" %> <% back_path = registrants_event_path(@event) %> +<% when "edit_callouts" %> + <% back_label = "← Edit callouts" %> + <% back_path = edit_event_path(@event, expand: "callouts", anchor: "registration_ticket_callouts") %> <% else %> <% back_label = "← Dashboard" %> <% back_path = dashboard_event_path(@event) %> @@ -53,4 +56,4 @@
-<%= render "event_registrations/ticket", event_registration: @event_registration, preview: true %> +<%= render "event_registrations/ticket", event_registration: @event_registration, preview: true, show_all: @show_all_options %> diff --git a/app/views/registration_ticket_callouts/show.html.erb b/app/views/registration_ticket_callouts/show.html.erb index ea78d733c..d6a5b7e93 100644 --- a/app/views/registration_ticket_callouts/show.html.erb +++ b/app/views/registration_ticket_callouts/show.html.erb @@ -1,7 +1,13 @@ <% content_for(:page_bg_class, "public") %> <% content_for(:page_title, "#{@callout.title} — #{@event.title}") %> <% reg_slug = params[:reg].presence %> -<% back_path = reg_slug ? registration_ticket_path(reg_slug) : nil %> +<%# Coming from the admin's sample-ticket preview, return there; a real registrant + returns to their ticket; otherwise the layout falls back to the event page. %> +<% back_path = if params[:return_to] == "sample_ticket" + sample_ticket_event_path(@event) + elsif reg_slug + registration_ticket_path(reg_slug) + end %> <%= render layout: "events/callouts/callout_page", locals: { title: @callout.title, back_path: back_path } do %> <% if @callout.subtitle.present? %>

<%= @callout.subtitle %>

diff --git a/config/routes.rb b/config/routes.rb index cf5f39ab2..f1641c215 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -135,6 +135,14 @@ member do get :dashboard get :sample_ticket + # Admin-only in-memory previews of the behavioral built-in callout pages, + # linked from the sample ticket. They reuse Events::CalloutsController's + # actions/views with an unsaved sample registration (see its sample mode). + get "sample_ticket/payment", to: "events/callouts#payment", defaults: { sample: "1" }, as: :sample_payment + get "sample_ticket/certificate", to: "events/callouts#certificate", defaults: { sample: "1" }, as: :sample_certificate + get "sample_ticket/scholarship", to: "events/callouts#scholarship", defaults: { sample: "1" }, as: :sample_scholarship + get "sample_ticket/ce", to: "events/callouts#ce", defaults: { sample: "1" }, as: :sample_ce + get "sample_ticket/videoconference", to: "events/callouts#videoconference", defaults: { sample: "1" }, as: :sample_videoconference get :background get :registrants get :onboarding diff --git a/spec/requests/events/registration_ticket_callouts_spec.rb b/spec/requests/events/registration_ticket_callouts_spec.rb index ecc92c63e..a7e8ea48c 100644 --- a/spec/requests/events/registration_ticket_callouts_spec.rb +++ b/spec/requests/events/registration_ticket_callouts_spec.rb @@ -16,6 +16,15 @@ expect(response.body).to include("Use the north lot.") end + it "points its back link at the sample ticket when opened from the preview" do + callout = create(:registration_ticket_callout, event:, title: "Parking", + description: "

Use the north lot.

") + + get event_registration_ticket_callout_path(event, callout, return_to: "sample_ticket") + + expect(response.body).to include(sample_ticket_event_path(event)) + end + it "redirects to the event when the callout has no description or resource" do callout = create(:registration_ticket_callout, event:, description: "") diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index 954d7ffa9..bc3d4f8be 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -150,27 +150,77 @@ def offer_ce!(target_event) context "as admin" do before { sign_in admin } - it "renders a preview ticket for an unsaved sample registration" do - create(:registration_ticket_callout, event: event) + it "renders a preview ticket for a data-free sample registration" do get sample_ticket_event_path(event) expect(response).to have_http_status(:ok) expect(response.body).to include("Sample ticket preview") - expect(response.body).to include("Sample Registrant") - # Built-in callout cards render (the sample event is paid, so the payment - # card shows) without raising on the unsaved sample's sentinel slug. - expect(response.body).to include("Make your payment") + expect(response.body).to include("Sample Person") + end + + it "never reads from a real registration, even when the event has one" do + registrant = create(:person, first_name: "Realname", last_name: "Smith") + create(:event_registration, event:, registrant:) + get sample_ticket_event_path(event) + expect(response.body).to include("Sample Person") + expect(response.body).not_to include("Realname") + end + + it "materializes the built-in callouts so the preview reads from real rows" do + expect { get sample_ticket_event_path(event) } + .to change { event.registration_ticket_callouts.builtin.count }.from(0).to(8) + end + + it "renders a published custom callout as a link to its detail page" do + callout = create(:registration_ticket_callout, event: event, + title: "Parking & directions", description: "

Lot B

") + get sample_ticket_event_path(event) + expect(response.body).to include("Parking & directions") + expect(response.body).to include( + event_registration_ticket_callout_path(event, callout, return_to: "sample_ticket") + ) + end + + it "omits an unpublished callout by default" do + create(:registration_ticket_callout, :hidden, event: event, title: "Draft note") + get sample_ticket_event_path(event) + expect(response.body).not_to include("Draft note") + end + + it "reveals unpublished built-in and custom callouts with ?options=all" do + create(:registration_ticket_callout, :hidden, event: event, title: "Draft note") + get sample_ticket_event_path(event, options: "all") + expect(response.body).to include("Draft note") + # The built-ins seed hidden, so they only appear here — and the ticket + # renders them against the unsaved sample's sentinel slug without raising. + expect(response.body).to include("Frequently asked questions") + end + + it "links behavioral built-in cards to their in-memory sample preview pages" do + get sample_ticket_event_path(event, options: "all") + expect(response.body).to include(sample_payment_event_path(event)) + end + + it "previews a published behavioral card even when the event config is incomplete" do + free = create(:event, cost_cents: 0) + BuiltinCallouts.seed(free) + # A free event has a payment "config gap" (BuiltinCalloutCards.config_gap) + # that hides the card on a real ticket; the preview shows it anyway so the + # admin can see and click it while finishing setup. + free.registration_ticket_callouts.find_by(builtin_key: "payment").update!(hidden: false) + get sample_ticket_event_path(free) + expect(response.body).to include(sample_payment_event_path(free)) end it "models a typical registrant by default, hiding scholarship and CE" do get sample_ticket_event_path(event) - expect(response.body).not_to include("Your scholarship request status") + expect(response.body).not_to include("Your scholarship request and award") expect(response.body).not_to include("CE hours") expect(response.body).to include("Show all options") end it "turns on every option with ?options=all" do get sample_ticket_event_path(event, options: "all") - expect(response.body).to include("Your scholarship request status") + expect(response.body).to include("Your scholarship request and award") expect(response.body).to include("CE hours") expect(response.body).to include("Show typical ticket") end @@ -190,6 +240,50 @@ def offer_ce!(target_event) end end + describe "sample callout previews" do + let(:event) { create(:event, ce_hours_offered: 6, videoconference_url: "https://example.com/vc") } + + sample_paths = { + "payment" => :sample_payment_event_path, + "certificate" => :sample_certificate_event_path, + "scholarship" => :sample_scholarship_event_path, + "ce" => :sample_ce_event_path, + "videoconference" => :sample_videoconference_event_path + } + + context "as admin" do + before { sign_in admin } + + sample_paths.each do |name, helper| + it "renders the #{name} preview for a data-free sample, back-linked to the ticket, without persisting" do + expect { get public_send(helper, event) } + .not_to change(EventRegistration, :count) + expect(response).to have_http_status(:ok) + expect(response.body).to include(sample_ticket_event_path(event)) + end + end + + it "does not expose a Pay action on the payment preview" do + event.update!(cost_cents: 5000) + get sample_payment_event_path(event) + expect(response.body).not_to include(registration_pay_path("sample")) + end + end + + context "as a non-admin" do + it "redirects a signed-in non-admin" do + sign_in user + get sample_payment_event_path(event) + expect(response).to redirect_to(root_path) + end + + it "requires login" do + get sample_payment_event_path(event) + expect(response).to redirect_to(new_user_session_path) + end + end + end + describe "GET /new" do context "as admin" do it "renders successfully" do @@ -266,6 +360,12 @@ def offer_ce!(target_event) expect(response.body).to include('name="event[ce_payment_due_deadline]"') expect(response.body).to include("Request CE credit by") end + + it "links to the sample ticket preview from the callouts section" do + get edit_event_path(event) + expect(response.body).to include("Preview sample ticket") + expect(response.body).to include(sample_ticket_event_path(event, return_to: "edit_callouts")) + end end describe "POST /create" do