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) soEventRegistrationSuccesscan 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_urlis unset,PublicEventPagedoes 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:
EventBuilderReviewSteppassesisPreview={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.capacityis set and greater than zero,create-event-checkoutrejects the order if committed tickets (sum ofquantity_sold+reserved_countacross allevent_ticket_typesfor the event) plus the requested ticket quantities would exceedcapacity. This is in addition to per-ticket-type inventory checks. - Public sold-out UI:
PublicEventPagestill usescapacityvstotal_registrationsfor messaging;total_registrationsis 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(andEventRegistrationSuccess) do not usereact-i18next. User-visible strings live in module-levelPC/RSobjects in those files. The signed-in app (Events Manager, Event Builder, etc.) still uses locale files undersrc/i18n/locales/*/events.json. - Locale cleanup: The
publicandregistrationsubtrees were removed fromevents.jsonin all locales—they were unused after the switch toPC/RS. Do not re-add them unless public pages adopt i18n again.
Sales Tax Support (Jan 22, 2026)
- Stripe Tax Integration: When
collect_sales_taxis 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-checkoutnow 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
EmbeddedEventPaymentFormcallscreate-event-checkoutwithaction: '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:
EventRegistrationSuccesspollsget-registration-details(up to 30 attempts at 2s intervals) untilisFinalized: 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 +PublicFootermirror the giving-page footer stack (06-EVENT-REGISTRATION-SUCCESS.md). - `get-registration-details` extended: Returns
registrationStatus,paymentStatus,stripePaymentIntentStatus, andisFinalized. Falls back to Stripe Checkout Session metadata when the DB row is not ready yet (webhook race). PostgREST: nestedevents → organizationsselects must use `organizations!events_organization_id_fkey` becauseeventshas two FKs toorganizations(organization_idandfund_id); an ambiguous embed caused 500 “Failed to look up registration” (EVENTS-EDGE-FUNCTIONS.md). - Real waitlist registration:
create-event-checkoutwithwaitlist: truenow creates an actualevent_registrationsrecord withis_waitlisted: trueandwaitlist_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'toPaymentStatus, added'pending'toRegistrationStatusintypes/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-checkoutedge function finds or creates a pendingevent_registrationsrecord + Stripe PaymentIntent →StripeProvider+EmbeddedEventPaymentFormrenders inline →stripe.confirmPayment({ redirect: 'if_required' })→ navigates to success page →EventRegistrationSuccesspolls for authoritative confirmation →stripe-webhookhandleEmbeddedEventPaymentconfirms 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:
collectSalesTaxflag 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-eventnow returnswaiver_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:
PublicEventPageblocks 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_urlis 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_urlis 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 fetchingEventRegistrationSuccess- 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