Saltar al contenido principal

<CheckoutForm>

Drop-in checkout form. Branches on whether the event is free or paid from useTicketingConfig() (enabled === true && stripe === null ↔ free, stripe !== null ↔ paid), mounts Stripe Elements for paid events, and runs the paid flow (checkout endpoint → capture PaymentMethod → stripe.confirmPayment). Fires the provider's paymentSucceeded or paymentFailed callback on terminal outcomes.

Import

import { CheckoutForm } from "@feelrift/react";

Basic usage

import { CheckoutForm, RiftEvent, RiftProvider } from "@feelrift/react";

<RiftProvider
on={{
paymentSucceeded: (orderId) => router.push(`/thanks?o=${orderId}`),
}}
>
<RiftEvent eventId="evt_summerfest_2026">
{/* … prior steps … */}
<CheckoutForm returnUrl="https://your.site/checkout/return" />
</RiftEvent>
</RiftProvider>;

Props

Accepts all HTMLAttributes<HTMLFormElement> plus:

NameTypeRequiredDefaultDescription
asChildbooleanNofalseSwap the root element via Radix Slot. The form behavior (submit handler, disabled states) is preserved.
returnUrlstring | ((response: CheckoutResponse) => string)No (required at runtime for paid events)URL Stripe redirects back to after confirmPayment. String for static returns; function for per-checkout URLs that encode order context. See returnUrl below.
submitLabelReactNodeNot("checkout.confirm") / t("checkout.confirmFree")One-off label for the submit button. Defaults differ for free vs paid events.
emailLabelReactNodeNot("checkout.emailLabel")Label for the email input.
emptyFallbackReactNodeNonullRendered when there's no active reservation.
successFallbackReactNodeNoBuilt-in success bannerRendered after a free order completes or after a paid confirmPayment resolves OK.
classNamestringNoAppended to the SDK's rift-checkout-form (and --free / --paid variant) class.
…restHTMLAttributes<HTMLFormElement>NoForwarded to the root element.

returnUrl

Required for paid events — Stripe needs somewhere to send the user after a redirect-required confirmation (3DS / iDEAL / Apple Pay fallback). The component throws at render time if it's omitted on a paid event:

<CheckoutForm> requires a `returnUrl` for paid events. Pass the URL
Stripe should redirect to after `confirmPayment` (e.g.
https://your.site/checkout/return).

The URL is consumer-owned because the SDK doesn't know the host's routing. Accept either form:

Static string — fine when the return page can read the persisted store (same origin, <RiftProvider> mounted on the return route):

<CheckoutForm returnUrl={`https://${host}/checkout/return`} />

Function — called synchronously between the server checkout response and stripe.confirmPayment. Receives the full CheckoutResponse (including orderId and orderStatusToken) so consumers can encode order context directly into the URL:

<CheckoutForm
returnUrl={({ orderId, orderStatusToken }) =>
`https://your.site/checkout/return?o=${orderId}&t=${encodeURIComponent(orderStatusToken)}`
}
/>

Use the function form when the post-redirect page can't reach the persisted store — a different origin, an SSR-rendered confirmation route, a return URL that opens in a new tab, or a setup where <RiftProvider> isn't mounted on the return page.

Note Redirect-required payment methods (3DS, BLIK, iDEAL, wallet redirects) unload the consumer's app. The SDK can't fire paymentSucceeded across the redirect — the URL is the only handoff. The function form is what makes that handoff stateless.

Warning The orderStatusToken carried in the redirect URL is a bearer credential — readable from browser history, the Referer header on outbound clicks, and server access logs. The return page must strip it from the URL on first render to close those leaks. See Securing the order-status token for the recommended pattern and the alternatives considered.

emptyFallback / successFallback

Both control transient render states:

  • emptyFallback — rendered when useReservation().data === null (no active reservation). Default null makes the component disappear when there's nothing to check out.
  • successFallback — rendered when the flow reaches its terminal success state (free orders OR paid confirmPayment resolved OK). Default is a <div class="rift-checkout-form__success"> carrying t("checkout.confirmFree").

For full post-purchase UI, leave both as defaults and route the user away in <RiftProvider on={{ paymentSucceeded }}> instead.

Render output

Two variants depending on useTicketingConfig().data:

  • Free (enabled === true && stripe === null): renders just the email field + submit button inside <form class="rift-checkout-form rift-checkout-form--free">.
  • Paid (stripe !== null): mounts Stripe <Elements> with the reservation's amount/currency and the SDK's tokensToStripeAppearance(), then renders the email field, the Stripe <PaymentElement>, and the submit button inside <form class="rift-checkout-form rift-checkout-form--paid">.

While Stripe.js loads (between mount and stripePromise resolving) the paid path renders a loading placeholder.

Behavior

  • Free path flow.
    1. User submits → useCheckout().prepare({ email }).
    2. Server returns success directly (payment: null).
    3. useCheckout sets status: "success" and fires on.paymentSucceeded(orderId, orderStatusToken).
  • Paid path flow.
    1. User submits → elements.submit() validates the <PaymentElement> locally.
    2. useCheckout().prepare({ email }) mints the order + PaymentIntent client secret on the server.
    3. useCheckoutPayment().capture() stores the PaymentMethod.
    4. useCheckout().confirm({ returnUrl }) charges the card. redirect: "if_required" keeps cards/wallets on-page; 3DS / iDEAL / Apple Pay redirect via returnUrl and resume there.
    5. On success, the SDK fires on.paymentSucceeded(orderId, orderStatusToken).
  • Eager Stripe.js load. <RiftEvent> triggers loadStripe() as soon as the Ticketing config arrives with a Stripe publishable key, so by the time the user reaches this form the script is already downloaded — no spinner gap.
  • Stripe deferred-flow contract. Elements mounts with mode: "payment" + amount + currency + paymentMethodCreation: "manual". The client secret only travels to stripe.confirmPayment; it is never passed to elements.update() (not part of the deferred-flow contract).
  • No clientSecret persistence. Reloading mid-checkout re-mints the PaymentIntent from the persisted reservation token. Stale PaymentIntents would produce raw Stripe errors.
  • Submit disable conditions (paid path):
    • Stripe or Elements not loaded.
    • Email empty.
    • A submit is in flight.
  • Error surfaces. Stripe-side errors and server-side RiftApiErrors render inline as .rift-checkout-form__error. For full UI control, omit the form and call useCheckout() directly.

Errors

Errors surfaced inside the form, all routed through useCheckout(). See the useCheckout errors table for full per-code coverage; the highlights:

SourceCodeWhen
useCheckoutreservation_token_expiredReservation TTL passed before submit.
useCheckoutreservation_no_longer_validPrevious attempt already completed.
useCheckoutcheckout_window_closedPast the reservation's checkoutDeadline.
useCheckoutvalidation_errorEmail failed Zod validation server-side.
Stripecard_declined, incorrect_cvc, etc.Stripe-side payment failure; carries the StripeError shape. Fires on.paymentFailed.

A <RiftProvider on={{ paymentFailed }}> callback receives both shapes; check instanceof RiftApiError to discriminate.

Examples

Standard usage

<RiftProvider
on={{
paymentSucceeded: (orderId) => router.push(`/thanks?o=${orderId}`),
paymentFailed: (err) => Sentry.captureException(err),
}}
>
<RiftEvent eventId="evt_summerfest_2026">
{/* … prior steps … */}
<CheckoutForm returnUrl={`https://${host}/checkout/return`} />
</RiftEvent>
</RiftProvider>

Custom success state

<CheckoutForm
returnUrl={`https://${host}/checkout/return`}
successFallback={
<div className="success">
<h2>You're in.</h2>
<p>Check your inbox for your tickets.</p>
</div>
}
/>

Replace the email label

<CheckoutForm
returnUrl={`https://${host}/checkout/return`}
emailLabel="Send tickets to"
/>

Submit-label override for the free branch

<CheckoutForm submitLabel="Reserve my spot" />

returnUrl is optional. The component renders the free flow without one. If the organizer reconfigures the event from free to paid server-side, the component surfaces a structured error prompting the consumer to supply a returnUrl — the form doesn't silently mount payment Elements with a missing redirect target.

Stateless return via URL-encoded order context

Encode the order's recovery handle into the redirect URL so the return page works without reading the persisted store:

<CheckoutForm
returnUrl={({ orderId, orderStatusToken }) =>
`https://your.site/checkout/return?o=${orderId}&t=${encodeURIComponent(orderStatusToken)}`
}
/>

On the return page, read the params and feed them straight into useOrderStatus:

"use client";

import { useSearchParams } from "next/navigation";
import { useOrderStatus } from "@feelrift/react";

export function ReturnPage() {
const params = useSearchParams();
const orderId = params.get("o") ?? undefined;
const token = params.get("t") ?? undefined;
const { data, isTerminal } = useOrderStatus({ orderId, token });
if (!data) return <p>Loading…</p>;
return isTerminal ? <Receipt order={data} /> : <p>Finalizing…</p>;
}

No useRiftStore reach-in, no localStorage dependency, no same-tab assumption.

Cross-domain confirmation page

The function form works across domains too — the SDK doesn't inspect or validate the URL:

<CheckoutForm
returnUrl={({ orderId, orderStatusToken }) =>
`https://confirmation.bigfest.com/r?o=${orderId}&t=${encodeURIComponent(orderStatusToken)}`
}
/>

The confirmation domain doesn't need to share storage or mount <RiftProvider> — it reads the params and uses useOrderStatus the same way the same-domain return page does.

Note The function is called synchronously and must return a string. Async URL building (server roundtrips, signed JWTs minted on demand) isn't supported by the prop directly — slow URL resolution would block the user-perceived redirect. For server-signed handoffs, sign on your backend's return endpoint after reading the URL-encoded orderId, rather than at URL-build time.