← All stories

TRUNK · public-event-page

Public Event Page

Persona: Public guest (no auth) Stage: Public Surface: vxge-aperture.porivo.com/p/:slug Branches estimated to root here: ~10

The most common surface across all the events Voyage hosts: the public event page. A guest arrives via email link, social share, or direct URL. They have ~3 seconds to decide whether this looks like a real event, who's hosting, when it is, and what they're meant to do. The trunk's job is to make those answers obvious without the workspace chrome that admins see — and to fail gracefully when the event is unavailable, archived, or invite-only.

Happy path

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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

SurfaceDiscoverabilityError UXLayoutOrientation
/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-testWherePurpose
public-event-pagePage /p/:slug rootRoot container for the public event surface
event-heroInside public-event-pageHero region with brand styling
event-nameInside event-heroEvent name as H1
event-dateInside event-heroEvent date + time + timezone
event-locationInside event-heroLocation text and optional map link
event-hostInside event-hero or headerHosting organization name
event-descriptionInside public-event-pageEvent description (Canvas-rendered)
event-scheduleInside public-event-pageSchedule / agenda card; absent for single-session events
event-speakersInside public-event-pageSpeakers card; absent if not configured
register-ctaInside event-hero AND footerThe primary registration action; label varies by access type
register-cta-secondaryFooter of public-event-pageSecondary placement of the same CTA for long pages
register-cta-priceInside register-cta regionPrice for public-purchase access types; absent for free events
capacity-contextNear register-ctaCapacity/waitlist context when relevant
event-not-found-pagePage in not-found state"Event not found" surface
event-archived-pagePage in archived state"No longer available" surface
event-archived-subscribe-ctaInside event-archived-pageSubscribe to future events; only if organizer enabled
invite-only-pagePage in invite-only-no-token statePer-EF-018 rejection contract
request-invitation-ctaInside invite-only-pagePrimary action — request access
language-switcherFooter of public-event-pageManual language picker; absent if event is single-language
view-other-dates-linkInside event-hero or near scheduleLink 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