← All stories

BRANCH · ef-031-registration-button-modal

Registration button modal

EF-031 Persona: Public guest (no auth) Stage: Public Roots in: public-event-page EF reference: EventFarm doc

A guest clicked Register. The modal opens. They fill name + email + maybe a few custom questions and submit. Sounds simple. The failure modes are dense: form validation, browser-back-while-modal-open, autofill quirks, mobile keyboards covering the submit button, two tabs racing, capacity-full mid-fill, network drops on submit. The contract is that none of those break the registration — the guest either succeeds, sees a clear path forward, or learns something useful.

Happy path

  1. Guest is on the public event page. Clicks the registration CTA.

    Modal opens (uses ui-modal). Browser URL gains #register hash so back-button closes the modal cleanly.

  2. Modal renders the form.

    Required fields by default: first name, last name, email. Custom questions configured by the organizer (EF-040) appear below the built-in fields. Each access type's specific fields render here too. The submit button label matches the access type's transaction type ("Register" for free, "Continue to checkout" for paid).

  3. Guest fills the form. Browser autofill works.

    Standard autocomplete attributes on each field: given-name, family-name, email. Browser autofill populates these correctly without triggering validation errors (per ui-text-input contract). Tab order is: first name → last name → email → custom questions → submit.

  4. Guest submits.

    Form validates client-side. If valid, POST /v1/public/events/:id/registrations. Loading state shows on the submit button. Form fields lock during submission.

  5. Success.

    Modal transitions to a confirmation state INSIDE the modal (no page reload). Confirmation card shows "You're registered for <Event name>" with calendar links (EF-058), confirmation email note, and a close button. Closing the modal returns to the public event page with the URL hash reverted.

Failure modes

Validation: required field missing

Trigger: guest submits with empty first name.

Per ui-form contract: inline errors on each missing field, focus moves to first errored field, no toast. Error message says what + how-to-fix: "First name is required." Submit button is NOT disabled (so guest can retry after fixing).

Recovery: Guest fills the field, submits again.

Validation: email format invalid

Trigger: guest types "alice@" or "alice@example" (missing TLD).

Inline error: "Please enter a complete email address." Pattern uses RFC 5321-compatible regex; not strict (allows quoted strings, IP-literal domains for edge cases). Validation runs on blur AND on submit, never on initial autofill (per ui-text-input contract).

Recovery: Fix email, resubmit.

Browser back-button while modal open

Trigger: guest opened modal, partially filled, hit browser back.

URL hash #register means back closes the modal cleanly without leaving the page. Form input is preserved IF the guest re-opens within the same session — using sessionStorage scoped to the URL. They don't lose what they typed. Closing the modal does NOT submit.

Recovery: Re-open modal; input restored.

Autofill triggers validation prematurely

Trigger: guest has saved login info; autofill fires on modal open and triggers validation, showing errors before the guest has typed anything.

Per ui-text-input contract: validation does NOT run on autofill events. The first validation pass is on submit OR on field blur (which only fires after autofill is done). Errors don't appear during the autofill cascade.

Recovery: Component contract handles it; harness asserts.

Mobile keyboard covers the submit button

Trigger: guest is on mobile, focused in the email field; keyboard opens; submit button is now hidden behind the keyboard.

The modal is a full-screen sheet on mobile (per ui-modal contract). Submit button is sticky to the bottom of the modal body, with the body scrolling above it when the keyboard pushes things up. Guest can always reach submit without dismissing the keyboard.

Recovery: Sticky-bottom submit; nothing more to do.

Network failure on submit

Trigger: guest clicked submit; connection dropped.

Per ui-form contract: form-error-banner appears above the fields with retry hint. Form input preserved. Retry includes Idempotency-Key so the server dedupes if the original landed.

Recovery: Retry. Idempotent.

Capacity reached mid-fill (FCFS race)

Trigger: guest started filling 2 minutes ago; another guest registered the last spot 30 seconds ago. This guest hits submit.

Server returns 409 with code CAPACITY_FULL. Modal shows: "Registration filled while you were typing. Would you like to join the waitlist?" with the guest's filled values pre-populated for a one-click waitlist conversion. Guest's effort is not lost.

Recovery: Convert to waitlist with one click.

Two-tab race — same guest registering twice in parallel

Trigger: guest opened the page in two tabs (e.g., bookmarked + clicked email link). Submitted in both.

Server uses email-as-uniqueness-key per event_id. The second submission returns 200 with body indicating the existing registration. Guest sees the same confirmation as the first — "You're registered." Backend does NOT create a duplicate row. Both tabs reach the same confirmation state.

Recovery: Idempotent server.

Server returns 500

Trigger: server crashed mid-registration.

Per ui-form contract: error banner with request_id from response header. Form preserved. Retry sends Idempotency-Key. Tone is calm: "Couldn't complete registration. Try again, or reference req_abc123 if you contact support."

Recovery: Retry; support has the ID.

Custom question with conditional logic

Trigger: organizer configured a question that's only required if a previous answer is "Yes" (EF-040 conditional questions).

Conditional fields appear/disappear based on the parent question. Required-only-when-shown — validation respects the condition. The harness asserts: answer "No" to parent, observe conditional field absent (per visibility-teeth: actually display:none is fine here because the field is intentionally not part of the form when not relevant). Answer "Yes," conditional field appears AND becomes required.

Recovery: Form respects conditional logic.

Custom question text is malicious / very long

Trigger: organizer pasted 5000 characters of HTML into a question label.

Question label is rendered as plain text (HTML entities escaped) with a max-display of 500 characters and a "Show more" if truncated. Server-side validation caps custom-question text at 1000 chars per question; longer text is truncated at the catalog level.

Recovery: Truncated at catalog AND render layers.

Confirmation calendar links fail to load

Trigger: confirmation state shows but the calendar links from EF-058 aren't generated (transient backend issue).

Per EF-058: links are generated synchronously in the confirmation render, OR (if async-generated) the confirmation card shows a loading state with "Calendar links will be available in a moment." that hydrates when ready. Confirmation is NOT blocked on calendar link availability.

Recovery: Calendar links hydrate; meanwhile guest can save the page.

Edge cases

Guest already registered

If a guest with the same email tries to register again, server returns 200 with the existing registration. Modal shows: "You're already registered for <Event name> (since <date>)" with calendar links. NOT an error — they're confirmed.

Free event with payment access type also exists

Some events have multiple access types (free + paid VIP). The modal includes an access-type picker at the top if >1 type is available. Guest picks; form fields adjust accordingly.

Embed-friendly modal (organizer embeds the form on their own site)

Out of scope for this branch. Embedding is a separate EF capability. The branch's modal assumes it's on the canonical event page.

Mobile keyboard with predictive input

Email field uses type="email" + inputmode="email" so the mobile keyboard shows the @ key prominently. Predictive text doesn't auto-correct email addresses.

Page evaluation

SurfaceDiscoverabilityError UXLayoutOrientation
Modal · open Modal title matches access type ("Register for <Event>" or "Buy tickets for <Event>"). Errors per ui-form contract; inline + summary if 2+. ui-modal full-screen sheet on mobile, max-width 540px on desktop. Modal title is the focused element on open; aria-modal=true.
Modal · submitting Submit button shows spinner + busy state. Form fields locked (readonly) during submit. No layout shift when busy state activates. aria-live="polite" announces "Submitting registration…"
Modal · confirmation state "You're registered" past-tense; calendar buttons visible. If post-success errors appear (e.g., calendar link generation fails), they don't block the confirmation message. Calendar buttons stack vertically on mobile. Heading shifts from "Register" to "Confirmed!" — clear state transition.
Modal · capacity-full mid-submit Waitlist conversion CTA is the primary action with the guest's input pre-populated. Calm tone: "Registration filled while you were typing." not "Failed." Waitlist context visible immediately. Guest's input doesn't disappear.

Acceptance signals

  • POST /v1/public/events/:id/registrations returns 200 (or 200 with already-registered body) with a registration_id.
  • D1 row exists in event_registrations with the submitted email + first/last name + access_type_id.
  • D1 row exists in event_guests linked to the new registration with status='confirmed'.
  • Confirmation email queued in notification_outbox (per EF-052 confirmation association).
  • Modal transitions to confirmation state without page reload.
  • URL hash returns to # (no #register) on modal close.
  • document.title remains the event page's title throughout (modal does not change document.title).
  • No console errors at severity ≥ warn.
  • Idempotency-Key header sent on retry attempts.

Stable test attributes

Branch-specific only; trunk and component attributes inherited.

Visibility teeth. Each attribute must be present AND effectively visible when the relevant state is active.

data-testWherePurpose
register-modalModal portal (uses ui-modal)Registration modal instance
register-formInside register-modalThe registration form (uses ui-form)
register-access-type-pickerInside register-formAccess type chooser; absent for single-type events
register-field-first-nameInside register-formFirst name input
register-field-last-nameInside register-formLast name input
register-field-emailInside register-formEmail input
register-custom-questionInside register-formEach custom question (multiple instances)
register-submit-ctaInside register-formSubmit button; label varies by access type
register-confirmationInside register-modal after successConfirmation card replacing the form
register-confirmation-h1Inside register-confirmation"You're registered" past-tense heading
register-confirmation-calendarInside register-confirmationCalendar buttons region (uses EF-058 attributes)
register-already-registeredInside register-modal · already-registered stateBody when guest already has a registration
register-capacity-fullInside register-modal · capacity-full state"Filled while you were typing" with waitlist CTA
register-waitlist-convert-ctaInside register-capacity-fullOne-click waitlist conversion
register-network-error-bannerTop of register-form (uses ui-form-error-banner)Reused from ui-form contract
register-modal-closeInside register-modal (uses ui-modal-close)Reused from ui-modal contract

Agent test plan

Inherits public-event-page trunk preconditions. Component contract chaining applies for ui-modal (register-modal instance), ui-form (register-form), ui-text-input (each register-field-*).

Probe list
- modal-opens-on-cta-click: from public-event-page, click register-cta, assert register-modal visible AND URL hash = #register
- modal-renders-form-fields: assert register-field-first-name, register-field-last-name, register-field-email all visible
- happy-path-submit: fill all fields with valid data, click register-submit-cta, assert API POST returns 200 AND register-confirmation visible AND register-confirmation-calendar visible
- email-validation-incomplete: fill "alice@", blur, assert ui-text-input-error visible with text matching "complete email"
- back-button-closes-modal: open modal, fill, browser back, assert modal closed AND URL hash empty AND form input preserved in sessionStorage
- back-then-reopen-restores-input: continuation, click register-cta again, assert input restored
- autofill-no-validation: simulate browser autofill, assert ui-text-input-error absent on all fields
- mobile-sticky-submit: at 375x667, focus email field (keyboard simulated), assert register-submit-cta still visible (not occluded by simulated keyboard)
- network-failure-with-retry: stub POST to abort, submit, assert ui-form-error-banner visible AND form input preserved; cleanup retry, assert Idempotency-Key header sent
- capacity-full-mid-fill: stub POST to 409 CAPACITY_FULL, submit, assert register-capacity-full visible AND register-waitlist-convert-cta visible AND form input still rendered
- waitlist-conversion: continuation, click register-waitlist-convert-cta, assert API POST to waitlist endpoint AND register-confirmation visible
- two-tab-race: stub POST to 200 with already-registered body on second call, submit twice, assert both reach register-confirmation
- 500-error-with-request-id: stub POST to 500 with X-Request-Id, assert ui-form-error-banner contains "req_"
- already-registered: navigate with existing-guest-email pre-filled, submit, assert register-already-registered visible (NOT register-confirmation — distinct state)
- conditional-field-shows-when-yes: answer parent question "Yes", assert conditional register-custom-question visible AND required
- conditional-field-hides-when-no: answer "No", assert conditional field absent (display:none acceptable; field is intentionally not in form)
- malicious-question-text-escaped: render with question label containing HTML, assert no actual HTML elements rendered (text content matches sanitized output)
- calendar-links-async-loading-state: stub calendar generation to delay 2s, assert confirmation appears WITHOUT calendar links AND a loading state visible