Skip to main content

PublicEventPage Component

PublicEventPage Component

Component: src/features/events/components/PublicEventPage.tsx Route: /events/:orgIdPrefix/:orgSlug/:eventSlug Access: Public (no authentication required)

---

Overview

The PublicEventPage is the public-facing event registration page. It displays event details, ticket options, and a registration form that integrates with Stripe Checkout for paid events.

Recent Updates

Success token + pending-email hardening (Apr 2026)

  • Token-preserving success redirects: All success navigation paths now carry the registration access token (token) so EventRegistrationSuccess can always verify the registration (embedded, free finalize, and PaymentElement return URL).
  • Stable pending-email actions: Pending cancel/finalize actions now reuse a stored normalized email snapshot from pending-registration creation, preventing backend mismatches if the visible email field is edited before cancel/finalize.

Registration Status Gating (Feb 2026)

  • Event Visibility Fix: Events are now always visible when published, even if registration is not yet open or has closed
  • Registration Status Banner: Displays "Registration Not Yet Open" or "Registration Has Closed" banners with relevant dates
  • Gated Sections: Ticket selection, registration form, order summary, and sold-out/waitlist states are hidden when registration is not open

Hero & media behavior (Mar 2026)

  • No empty hero band: When image_url is unset, PublicEventPage does not render a full-width gradient hero; the event card starts below the sticky org header with normal padding.
  • `image_url` handling:
  • YouTube (youtube.com / youtu.be): hero uses an iframe embed.
  • Direct video file (URL path ending in .mp4, .webm, .ogg, .mov, .m4v): HTML5 `<video>` controls.
  • Everything else (uploaded images, VideoBlast thumbnail URLs, etc.): hero image (background / cover). VideoBlast in the builder stores a thumbnail URL, not an inline video player.
  • Builder preview: EventBuilderReviewStep passes isPreview={true} for a shorter hero when media exists and a taller scroll container (max-h-[min(85vh,900px)]).

Event capacity vs checkout (Mar 2026)

  • Venue cap: When events.capacity is set and greater than zero, create-event-checkout rejects the order if committed tickets (sum of quantity_sold + reserved_count across all event_ticket_types for the event) plus the requested ticket quantities would exceed capacity. This is in addition to per-ticket-type inventory checks.
  • Public sold-out UI: PublicEventPage still uses capacity vs total_registrations for messaging; total_registrations is incremented per completed registration row (see DB triggers). For multi-ticket orders, rely on checkout + ticket aggregates for hard enforcement.

Public copy policy (Mar 2026)

  • English-only on public routes: PublicEventPage (and EventRegistrationSuccess) do not use react-i18next. User-visible strings live in module-level PC / RS objects in those files. The signed-in app (Events Manager, Event Builder, etc.) still uses locale files under src/i18n/locales/*/events.json.
  • Locale cleanup: The public and registration subtrees were removed from events.json in all locales—they were unused after the switch to PC / RS. Do not re-add them unless public pages adopt i18n again.

Sales Tax Support (Jan 22, 2026)

  • Stripe Tax Integration: When collect_sales_tax is enabled on the event, the checkout flow passes this to the Edge Function
  • Automatic Tax Calculation: Stripe Tax calculates applicable sales tax based on attendee location
  • Tax Code: Event tickets use Stripe Tax code txcd_90010001 (Admission to events)

Idempotent Checkout & Authoritative Finalization (Mar 6, 2026)

  • Server-side duplicate prevention: create-event-checkout now detects existing pending registrations for the same email+event+ticket combination and reuses or cancels them instead of creating duplicates. Email addresses are normalized (lowercased, trimmed) for matching.
  • Pending-registration cancellation: Cancel button in EmbeddedEventPaymentForm calls create-event-checkout with action: 'cancel_pending_registration' to release ticket holds and cancel the Stripe PaymentIntent. If the payment already succeeded/is finalizing, the user is redirected to the success page instead.
  • Authoritative success page: EventRegistrationSuccess polls get-registration-details (up to 30 attempts at 2s intervals) until isFinalized: true. Spinner while finalizing; if polling stalls or the API errors, a top bar shows Return to Event Page (left) and Retry status check (right). No “check email” CTA on error (copy explains email only when stalled). Confirmed success shows Add to Calendar + Share only (no back button on that screen); fund contact line + PublicFooter mirror the giving-page footer stack (06-EVENT-REGISTRATION-SUCCESS.md).
  • `get-registration-details` extended: Returns registrationStatus, paymentStatus, stripePaymentIntentStatus, and isFinalized. Falls back to Stripe Checkout Session metadata when the DB row is not ready yet (webhook race). PostgREST: nested events → organizations selects must use `organizations!events_organization_id_fkey` because events has two FKs to organizations (organization_id and fund_id); an ambiguous embed caused 500 “Failed to look up registration” (EVENTS-EDGE-FUNCTIONS.md).
  • Real waitlist registration: create-event-checkout with waitlist: true now creates an actual event_registrations record with is_waitlisted: true and waitlist_position, returning the position to the frontend. Duplicate waitlist joins are detected and return the existing position.
  • `PaymentStatus` / `RegistrationStatus` types expanded: Added 'processing' and 'failed' to PaymentStatus, added 'pending' to RegistrationStatus in types/events.ts.

Embedded Payment Portal (Mar 2026)

  • On-page Stripe PaymentElement: Paid event registrations now use an embedded Stripe PaymentElement directly on the event page instead of redirecting to Stripe Checkout. The user never leaves the page.
  • Flow: User selects tickets → fills registration form → clicks "Pay" → create-event-checkout edge function finds or creates a pending event_registrations record + Stripe PaymentIntent → StripeProvider + EmbeddedEventPaymentForm renders inline → stripe.confirmPayment({ redirect: 'if_required' }) → navigates to success page → EventRegistrationSuccess polls for authoritative confirmation → stripe-webhook handleEmbeddedEventPayment confirms the registration server-side
  • Idempotency: If the user clicks "Pay" again with the same email/tickets, the server reuses the existing pending PaymentIntent (if still reusable) or cancels the stale one and creates a fresh attempt. Payments already finalizing redirect to the success page.
  • Tax collection: collectSalesTax flag passes through to PaymentIntent metadata for Stripe Tax
  • Free events: Still bypass Stripe entirely (direct DB insert → success page)
  • Fallback: The Stripe Checkout Session redirect path (Mode 1) is preserved in the edge function for non-embedded callers

Registration-time waiver gating (Mar 2026)

  • Event waiver requirements in payload: get-public-event now returns waiver_requirements (event checklist waiver items with waiver metadata) so the registration UI can enforce required signatures.
  • During-registration signing: Required waivers are now signed during registration (after pending registration creation, before payment completion) using PublicWaiverSigning.
  • Payment gate: PublicEventPage blocks payment completion until all required waivers are signed.
  • Status refresh: Waiver completion is refreshed from get-checklist-portal?token=..., and the payment form unlocks when required items are approved/completed.

Features

  • Hero section - Shown only when image_url is set (image, YouTube embed, or direct video file); otherwise content starts without a tall band
  • Event details - Date, time, location/virtual link
  • Ticket selection - Multiple ticket types with quantity controls
  • Promo codes - Apply discounts
  • Registration form - Name, email, phone, custom fields
  • Order summary - Real-time total calculation
  • Required waiver signing - Required event waivers must be signed before payment completion
  • Embedded Stripe payment - On-page PaymentElement (no redirect)
  • Sold out/waitlist states - Graceful handling of capacity
  • Registration status gating - Banners for not-yet-open / closed registration

UI Layout

┌─────────────────────────────────────────────────────────┐
│ ┌─────────────────────────────────────────────────────┐ │
│ │                                                     │ │
│ │              [Event Hero Image]                     │ │
│ │                                                     │ │
│ │              Event Name                             │ │
│ │              Organization Name                      │ │
│ └─────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  📅 December 25, 2025 at 6:00 PM                       │
│  📍 Venue Name, City, State                            │
│                                                         │
│  Event description text goes here...                    │
│                                                         │
├─────────────────────────────────────────────────────────┤
│  Select Tickets                                         │
│  ┌─────────────────────────────────────────────────────┐│
│  │ General Admission          $50    [-] 2 [+]        ││
│  │ VIP Package               $100    [-] 0 [+]        ││
│  └─────────────────────────────────────────────────────┘│
│                                                         │
│  Promo Code: [________] [Apply]                        │
│                                                         │
├─────────────────────────────────────────────────────────┤
│  Your Information                                       │
│  ┌─────────────────────────────────────────────────────┐│
│  │ First Name: [________]  Last Name: [________]      ││
│  │ Email: [________________]                          ││
│  │ Phone: [________________]                          ││
│  │                                                     ││
│  │ [Custom Fields...]                                 ││
│  └─────────────────────────────────────────────────────┘│
│                                                         │
├─────────────────────────────────────────────────────────┤
│  Order Summary                                          │
│  2x General Admission                          $100.00  │
│  Promo: SAVE20                                 -$20.00  │
│  ─────────────────────────────────────────────────────  │
│  Total                                          $80.00  │
│                                                         │
│  [Complete Registration]                                │
└─────────────────────────────────────────────────────────┘

Props

interface PublicEventPageProps {
  config: {
    id: string;
    slug: string;
    organizationId: string;
    organizationSlug: string;
    organizationName: string;
    name: string;
    description?: string;
    event_type: string;
    start_date: string;
    end_date?: string;
    timezone: string;
    location_name?: string;
    location_address?: string;
    is_virtual: boolean;
    virtual_url?: string;
    capacity?: number;
    total_registrations: number;
    waitlist_enabled: boolean;
    image_url?: string;
    status: string;
    ticket_types: TicketType[];
    custom_fields: CustomField[];
    has_checklist: boolean;
    // Registration status (added Feb 2026)
    registration_status?: 'open' | 'not_open_yet' | 'closed';
    registration_open_date?: string;
    registration_close_date?: string;
    // Tax compliance (added Jan 2026)
    collect_sales_tax?: boolean;
    // Registration-time waiver requirements (added Mar 2026)
    waiver_requirements?: Array<{
      checklist_item_id: string;
      waiver_id: string;
      name: string;
      description?: string;
      required: boolean;
      sort_order?: number;
      waiver_name?: string;
      waiver_file_name?: string;
      waiver_file_url?: string;
    }>;
  };
}

State Management

const [ticketSelections, setTicketSelections] = useState<Record<string, number>>({});
const [promoCode, setPromoCode] = useState('');
const [appliedPromo, setAppliedPromo] = useState<PromoCode | null>(null);
const [registrantInfo, setRegistrantInfo] = useState({
  firstName: '',
  lastName: '',
  email: '',
  phone: ''
});
const [pendingRegistrantEmail, setPendingRegistrantEmail] = useState<string | null>(null);
const [customFieldValues, setCustomFieldValues] = useState<Record<string, string>>({});
const [isSubmitting, setIsSubmitting] = useState(false);

Checkout Flow (Embedded PaymentElement)

const handleProceedToCheckout = async () => {
  const normalizedRegistrantEmail = registrantInfo.email.trim().toLowerCase();
  setIsProcessing(true);
  try {
    const response = await fetch(`${SUPABASE_URL}/functions/v1/create-event-checkout`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        eventId: config.id,
        eventSlug: config.slug,
        organizationId: config.organizationId,
        tickets,
        registrantEmail: normalizedRegistrantEmail,
        registrantFirstName,
        registrantLastName,
        registrantPhone,
        customFields,
        promoCode: promoApplied?.code,
        collectSalesTax: config.collect_sales_tax || false,
        embedded: true, // Use on-page PaymentElement (not Stripe Checkout redirect)
      })
    });
    const data: EventCheckoutResponse = await response.json();

    if (data.registrationId) {
      setPendingRegistrationId(data.registrationId);
      setPendingRegistrantEmail(normalizedRegistrantEmail);
    }

    if (data.free && data.registrationId) {
      navigateToRegistrationSuccess(data.registrationId, data.accessToken || pendingRegistrationToken);
    } else if (data.finalizeRegistration && data.registrationId) {
      // Payment already succeeded/processing — skip to success page
      toast.success('Payment received. Finalizing your registration...');
      navigateToRegistrationSuccess(data.registrationId, data.accessToken || pendingRegistrationToken);
    } else if (data.clientSecret) {
      // Embedded payment — show inline Stripe PaymentElement
      setPaymentClientSecret(data.clientSecret);
      setShowPaymentForm(true);
    }
  } catch (error) {
    toast.error('Failed to process registration');
  } finally {
    setIsProcessing(false);
  }
};

Response contract from create-event-checkout

| Field | Type | Meaning |
|-------|------|---------|
| `free` | `boolean` | Free registration completed server-side |
| `clientSecret` | `string` | Stripe PaymentIntent client secret for embedded form |
| `registrationId` | `string` | Pending (or confirmed) registration UUID |
| `finalizeRegistration` | `boolean` | Payment already succeeded — skip to success page |
| `paymentStatus` | `string` | Current Stripe PaymentIntent status |
| `waitlisted` | `boolean` | Waitlist registration created |
| `alreadyWaitlisted` | `boolean` | Email was already on the waitlist |
| `waitlistPosition` | `number` | Position on the waitlist |

Waiver flow during registration

1. User fills tickets + registration form. 2. User clicks continue/pay → create-event-checkout creates/reuses pending registration. 3. If required waivers exist, payment remains blocked until required waivers are signed. 4. User signs waiver(s) in PublicWaiverSigning (/waivers/sign/:waiverId?registration=...&checklist=...&token=...). 5. PublicEventPage refreshes checklist status and unlocks payment when all required waivers are approved/completed.

Cancel flow

When the user clicks Cancel in EmbeddedEventPaymentForm, handlePaymentCancel calls create-event-checkout with action: 'cancel_pending_registration' + registrationId + a stored normalized pending email snapshot (not the mutable current input field). If the payment has already succeeded/is finalizing, the response includes finalizeRegistration: true and the user is redirected to the success page instead of returning to the form.

The EmbeddedEventPaymentForm (defined outside the main component) uses useStripe() + useElements() inside a StripeProvider wrapper to call stripe.confirmPayment({ redirect: 'if_required' }). On success it passes the paymentIntent.status to onSuccess(status), which navigates to the success page. The EventRegistrationSuccess page then polls get-registration-details for authoritative confirmation. The stripe-webhook handleEmbeddedEventPayment confirms the pending registration server-side.

The legacy Stripe Checkout Session redirect path (Mode 1) is preserved in the edge function for non-embedded callers but is not used by the current frontend.

Promo Code Validation

The live implementation calls `validate-promo-code` (not validate-promo):

const handleApplyPromo = async () => {
  const response = await fetch(`${SUPABASE_URL}/functions/v1/validate-promo-code`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      eventId: config.id,
      code: promoCode.toUpperCase(),
      subtotal,
    }),
  });
  // On success: { discount } — see Edge Function contract
};

Virtual events & hero media

  • `virtual_url`: When the event is virtual and virtual_url is set, the public page shows a Join online link (opens in a new tab).
  • Hero `image_url`: YouTube URLs embed as an iframe; direct video files (e.g. .mp4, .webm) render with a <video> element; other URLs use the photo hero with overlay.

Registration Status States

| `registration_status` | Display |
|-----------------------|---------|
| `open` (or absent) | Normal ticket selection + registration form |
| `not_open_yet` | Banner: "Registration Not Yet Open" with opening date if available |
| `closed` | Banner: "Registration Has Closed" with contact message |

When registration is not open, the following sections are hidden:

  • Ticket selection
  • Registration form
  • Order summary / checkout
  • Sold-out / waitlist states

Event details (hero, description, date/location) are always visible.

Capacity States

| State | Display |
|-------|--------|
| Available | Normal registration form |
| Sold Out (no waitlist) | "Sold Out" message, no form |
| Sold Out (waitlist enabled) | "Join Waitlist" email input + button → creates real `event_registrations` record with `is_waitlisted: true` |
| Already on waitlist | Toast: "You're already on the waitlist" with position number |
| Waitlist Full | "Event Full" message |

Related Components

  • PublicEventLoader - URL parsing and data fetching
  • EventRegistrationSuccess - Post-registration confirmation

Related Documentation

  • Main Events Doc: ../06-EVENTS-TICKETING.md
  • Registration Success: ./06-EVENT-REGISTRATION-SUCCESS.md

Synced from IFMmvp-Frontend documentation: pages/fundraising/events/05-PUBLIC-EVENT-PAGE.md

Ready to Get Started?

See how Alignmint can simplify your nonprofit's operations. Schedule a free demo with our team and we'll walk you through everything.

Questions? Email us at steven@getalignmint.org

Ready to get started?Start Plus Trial