Happy path
-
Guest navigates to
vxge-aperture.porivo.com/p/<event-slug>.No login. The booking-pages app serves the route. Initial paint shows the event hero (image / brand color block + name) within ~500ms even on slow connections.
-
Page renders the event identity.
Hero with event name as H1, date + time + timezone, location (with map link if address-style location), hosting organization name, and brand styling pulled from the event's Canvas-driven design.
-
Guest scrolls; sees the description, schedule (if any), speakers (if any), and the registration CTA.
CTA is visible in the hero AND repeated at the bottom for long pages. CTA's label depends on access type — "Register," "Buy ticket," "RSVP," "Request invitation." Public-pricing access types show a price near the CTA.
-
Guest clicks the CTA.
For public-registration events: opens the registration modal (EF-031). For invite-to-RSVP: redirects to the invite-required path (EF-018). For invite-to-purchase: similar with payment flow. For public-purchase: registration modal followed by Stripe checkout.
-
Social-share metadata renders correctly.
Open Graph tags on the page produce a rich preview when the URL is pasted into Slack, iMessage, Twitter, LinkedIn. Title = event name, description = first 160 chars of event description, image = event hero image.
Failure modes
Event not found
Trigger: guest follows a link with a slug that doesn't match any event (typo, deleted event, never existed).
Page shows a clean "Event not found" with the slug echoed back ("We couldn't find an event at /p/<wrong-slug>") and a search affordance that lets them check Voyage's public directory if their hosting org has one. Does NOT distinguish "deleted" from "never existed" — same UX, same content. document.title is "Event not found · Voyage" not the slug.
Recovery: Guest checks the link they were sent or contacts the organizer.
Event is archived
Trigger: event slug resolves but the event was archived after invitations went out.
Page shows the event name + date + "This event is no longer available." with a follow-up affordance if the organizer enabled future-events subscription. NOT the same "not found" message — distinguishing here is appropriate because the guest can verify they had the right URL.
Recovery: Subscribe to future events from this organizer.
Event is draft (not yet published)
Trigger: organizer shared the URL with a colleague but hasn't published yet.
Same as "Event not found" from the public's perspective — drafts don't leak to the public surface. Organizers viewing a draft via the admin should see a preview banner indicating the event isn't live; the public surface treats it as not-found. This is anti-leak behavior, not user-hostile.
Recovery: Wait for organizer to publish.
Event is at capacity (public-registration variant)
Trigger: public event is at capacity. CTA would be "Register" but registrations are closed.
CTA label changes to "Join waitlist" if waitlist is enabled, or "Registrations closed" (disabled) if not. The page does NOT hide the registration affordance entirely — guests want to know they were close. Capacity context is shown ("This event reached its 200-guest capacity") so the guest understands the state.
Recovery: Join waitlist (if available) or move on.
Event is invite-only and the guest came without a token
Trigger: guest hits /p/:slug without an invite token; the event is invite-to-RSVP.
Page shows the event identity (if organizer enabled "show event title to non-invitees") plus a clear note: "This is a private event. Invitations are personal — please use the link from your invitation email." A request-invitation affordance offers a path. NEVER reveals access types or capacity for invite-only events; just the title-and-date if enabled, otherwise a generic "private event" page.
Recovery: Guest checks email or requests an invitation.
Slow / unreliable mobile network
Trigger: guest opens the page on a poor 3G connection at the venue.
Hero paints with brand color + event name from the HTML payload (no-JS-required for first paint). JavaScript-dependent enhancements (CTA modal, schedule expansion) hydrate progressively. The page is fully usable for "see what this event is" without any JS executing. document.title is set in HTML, not via JavaScript.
Recovery: Page renders even on the slowest connections.
Browser back-button after closing the registration modal
Trigger: guest opened the modal (EF-031), browser-back closes it; guest clicks back again expecting to leave the event page.
Modal open state should be in browser history (a hash fragment or query param) so back closes it. After modal close, another back leaves the event page entirely. Without this, back-button has unpredictable behavior — either jumps two steps in some browsers or does nothing in others.
Recovery: Back button behaves intuitively.
Guest pastes the URL into Slack — bad preview
Trigger: organizer wants to share. Slack/iMessage/etc. fetch the URL. The OG metadata is missing or malformed.
Page must serve correct Open Graph + Twitter Card meta tags in the initial HTML. Title = event name, description = first 160 chars stripped of markdown, image = hero image at ≥1200px wide, type = website. URL canonicalization: even a deep-link fragment serves OG tags from the event root.
Recovery: Trust the OG tags to do their job; harness asserts they're present.
Edge cases
Multi-language event
Page detects browser language from Accept-Language and renders matching content if available. Manual language switcher in the page footer. URL preserves the language preference via path or query.
Multi-occurrence event series
The slug resolves to a specific occurrence. The page may include a "View other dates" affordance that links to the series view if the organizer enabled it.
Mobile viewport (375×667) is the dominant case
Most public traffic is mobile. Single-column flow, hero scales to viewport, CTA is sticky-to-bottom on long pages so it doesn't require scrolling-to-end to register.
Print or save-as-PDF
Print stylesheet hides the registration CTA but preserves the event identity, date, location. Useful for a guest who wants a hard copy of where they're going.
Page evaluation
| Surface | Discoverability | Error UX | Layout | Orientation |
|---|---|---|---|---|
| /p/:slug (event found) | Registration CTA visible above the fold at all viewports ≥ 320px wide. Sticky on mobile. | Per-section error states (e.g., schedule fetch fails) isolated; the rest of the page renders. | Hero → description → schedule → speakers → footer. Single-column on mobile, optionally two-column at ≥ 1024px. | Event name as H1. document.title = "<Event name> · <Hosting org>". Hosting org visible in header. |
| /p/:slug (not found) | Suggested actions are visible (search organizer's directory, contact). | Tone is calm, not blame-shifting. "We couldn't find" not "You entered an invalid slug." | Centered card, max-width 640px. | document.title = "Event not found · Voyage", does NOT contain the slug. |
| /p/:slug (archived) | Event identity visible. Subscribe-to-future affordance if available. | Past-tense framing. | Centered card. | document.title = "<Event name> (no longer available) · Voyage". |
| /p/:slug (invite-only-no-token) | Request-invitation affordance is the primary action. | Per EF-018 rejection contract: warm copy, no leak of access types. | Centered card. | If show-title-disabled: generic title. If enabled: event name visible. |
Acceptance signals
- URL matches
^https?://[^/]+/p/[a-z0-9-]+/?$. - Event hero with H1 = event name renders within 500ms (TTFB + first paint).
- document.title is set in HTML (no JS required) and matches the event name.
- Open Graph meta tags are present: og:title, og:description, og:image, og:url, og:type.
- Twitter Card meta tags are present: twitter:card = "summary_large_image", twitter:title, twitter:description, twitter:image.
- No console errors at severity ≥
warn. - TTI under 3s on broadband, under 8s on simulated 3G.
- No layout shift > 0.1 in the first 5s.
- Page is accessible without JavaScript for the hero and primary information; only the registration CTA's modal requires JS.
Stable test attributes
Visibility teeth. Each attribute must be present AND effectively visible when the relevant surface state is active. Hiding without removal is a Ratchet violation.
| data-test | Where | Purpose |
|---|---|---|
public-event-page | Page /p/:slug root | Root container for the public event surface |
event-hero | Inside public-event-page | Hero region with brand styling |
event-name | Inside event-hero | Event name as H1 |
event-date | Inside event-hero | Event date + time + timezone |
event-location | Inside event-hero | Location text and optional map link |
event-host | Inside event-hero or header | Hosting organization name |
event-description | Inside public-event-page | Event description (Canvas-rendered) |
event-schedule | Inside public-event-page | Schedule / agenda card; absent for single-session events |
event-speakers | Inside public-event-page | Speakers card; absent if not configured |
register-cta | Inside event-hero AND footer | The primary registration action; label varies by access type |
register-cta-secondary | Footer of public-event-page | Secondary placement of the same CTA for long pages |
register-cta-price | Inside register-cta region | Price for public-purchase access types; absent for free events |
capacity-context | Near register-cta | Capacity/waitlist context when relevant |
event-not-found-page | Page in not-found state | "Event not found" surface |
event-archived-page | Page in archived state | "No longer available" surface |
event-archived-subscribe-cta | Inside event-archived-page | Subscribe to future events; only if organizer enabled |
invite-only-page | Page in invite-only-no-token state | Per-EF-018 rejection contract |
request-invitation-cta | Inside invite-only-page | Primary action — request access |
language-switcher | Footer of public-event-page | Manual language picker; absent if event is single-language |
view-other-dates-link | Inside event-hero or near schedule | Link to series view if multi-occurrence |
Agent test plan
This trunk runs against the booking-pages app surface (vxge-aperture.porivo.com/p/:slug). Public surface — no auth required. Branches that root here include EF-031 (registration button modal), EF-016 (public registration), EF-017 (public purchase) when those branches are written.
Trunk setup probes (consumed as preconditions by branches)
preconditions: none (no auth)
happyPath:
1. navigate to ${BOOKING_BASE_URL}/p/${fixture.eventSlug}
2. wait for [data-test=public-event-page]
3. assert event-hero visible, event-name as H1, event-date and event-location visible
4. assert register-cta above the fold at 1280x800 AND at 375x667
5. assert document.title contains event name AND organization name
6. assert OG meta tags present in DOM head
7. assert TTI under 3s broadband, under 8s 3g
8. assert no console errors from voyage origins
Failure-mode probes
- not-found: navigate to /p/wrong-slug, assert event-not-found-page AND document.title = "Event not found · Voyage" AND slug does not appear in title
- archived: navigate to /p/${fixture.archivedSlug}, assert event-archived-page AND optional subscribe CTA
- draft-as-not-found: navigate to /p/${fixture.draftSlug}, assert same content as not-found
- capacity-full: navigate to /p/${fixture.fullCapacitySlug}, assert register-cta says "Join waitlist" OR is disabled with capacity-context visible
- invite-only-no-token: navigate to /p/${fixture.inviteOnlySlug}, assert invite-only-page AND request-invitation-cta visible
- invite-only-show-title-disabled: with show-title-disabled fixture, assert event-name absent from rendered body
- slow-network: throttle slow-3g, navigate, assert event-hero paints within 2s, full TTI under 8s
- modal-back-button: open registration modal, browser back, assert modal closed AND URL reverted to /p/:slug
- og-tags-present: navigate, assert HEAD contains og:title, og:description, og:image, og:url, og:type
- og-tags-with-fragment: navigate to /p/:slug#schedule, assert OG tags identical to bare /p/:slug