Pricing and estimates
How Rift prices ticket types across waves, what the SDK shows the attendee at each step, and the disclaimer organizers are required to surface.
The wave-tier model
Organizers configure events with one or more ticket types (General admission, VIP, Backstage). The same ticket type can be priced differently across waves — pricing/scheduling tiers the organizer uses to reward early commitment or to throttle demand. Typical shape:
| Wave | GA price | VIP price | Window |
|---|---|---|---|
| Early bird | $80 | $200 | Until 2 weeks before |
| Regular | $100 | $250 | Until day of |
| At the door | $120 | $300 | Day-of only |
Waves overlap when the organizer wants them to (e.g. early bird remaining capacity continues to sell at $80 alongside the regular-priced wave at $100).
The attendee never picks a wave. The reservation service
walks waves cheapest-first when fulfilling, so a { ticketTypeId, quantity } request gets the best available price automatically.
Wave names exist server-side for the breakdown the SDK shows in
the estimate; they are not a selection concept.
What the SDK shows where
The SDK collapses waves at every UI seam so the picker stays a flat list of ticket types and the price breakdown surfaces only when it matters:
| Surface | Wave concept visible? |
|---|---|
<AvailabilityList> | No. One row per ticket type. Cheapest active wave's price shown as "From $X". |
<TicketRow> | No. Aggregated price + stepper clamped at sum(available) and the most permissive maxPerOrder across active waves with inventory. |
<EstimateBreakdown> (pre) | Yes — the breakdown is the whole point. Hierarchical: ticket type → split lines per consumed wave → subtotal → grand total + disclaimer. |
<ReservationSummary> (post) | Yes — same shape, server-confirmed data. Wave names resolved from the cached availability tree. |
The greedy-fill algorithm
useSelectionEstimate mirrors what the reservation service does
server-side. For each ticket type with quantity > 0:
- Order its currently-active waves by
unitPriceascending (cheapest first).useTicketTypesdoes this aggregation. - Walk:
take = min(remaining, wave.available); record a line{ waveId, quantity: take, unitPrice }; subtracttakefrom remaining. - Stop when remaining hits 0 (the stepper clamping prevents exhausting waves before satisfying remaining).
The pricing nuance worth internalizing: unit price × quantity
is wrong when quantity exceeds the cheapest wave's remaining.
Example: GA has 1 left at Early bird ($80), unlimited at Regular
($100). Attendee wants 2 → pays $80 + $100 = $180, not
$80 × 2 = $160. The greedy fill produces the right number; the
breakdown shows them where the math came from.
Active-wave filtering
The Experiences API returns every wave on an event with its
startsAt/endsAt windows, regardless of whether it's currently
selling. useTicketTypes filters to active waves
(startsAt <= getNow() <= endsAt) before aggregating — so
expired waves' stale prices never enter the estimate and not-yet-
live waves don't get sold against.
For test and Storybook scenarios, pass a frozen clock to control which waves are considered active:
<EstimateBreakdown now={() => new Date("2026-07-01T12:00:00Z")} />
<AvailabilityList now={() => new Date("2026-07-01T12:00:00Z")} />
The disclaimer
<EstimateBreakdown> and <ReservationSummary> ship a
default-visible disclaimer near the total. Two reasons:
- Cheapest-first tier selection. Attendees benefit from knowing the algorithm — it's why a $80 ticket can show up alongside a $100 ticket on the same line item.
- Modifier transparency. Server-side modifiers (platform
fees, taxes, discounts) are computed in the pricing engine
and only appear in the reservation/checkout response's
breakdown.steps. The estimate shows line items only; the final total can differ. Pricing-transparency rules in many jurisdictions require this kind of "fees added at checkout" call-out.
Default copy (en):
"We pick the cheapest available tier first. Final price (including any fees) is confirmed when you reserve."
Override via the disclaimer prop on either component, or globally
via <RiftProvider strings={{ "estimate.disclaimer": "…" }}>.
disclaimer={null} hides it entirely. Don't unless you display
equivalent copy elsewhere on the page — the default is the
regulatory-friendly path.
What's NOT in the estimate
The client-side estimate covers line items: (ticketType, wave, quantity, unitPrice). It does NOT cover:
- Platform fees (a percentage modifier the platform adds).
- Per-order fixed fees (booking fees, processing fees).
- Taxes (region-specific, organizer-configurable).
- Discount codes (haven't been applied at estimate time).
Those live in the reservation response's breakdown.steps. When
the server's total differs from the client's estimate,
useReservation().priceChanged flips true and
<PriceChangedWarning> surfaces the delta. The disclaimer
prepares the attendee for that possibility before they commit.
Bypassing the components
Direct hook access for custom UIs:
import { useSelectionEstimate, useTicketTypes } from "@feelrift/react";
function CustomCart() {
const { data: ticketTypes } = useTicketTypes();
const { data: estimate } = useSelectionEstimate();
if (!estimate) return null;
return (
<div>
<h2>{estimate.ticketTypes.length} ticket types selected</h2>
<strong>{formatCurrency(estimate.subtotal)}</strong>
{/* Render lines / disclaimer however you like */}
</div>
);
}
The pure functions are exported too for unit testing or non-React contexts:
aggregateTicketTypes(availability, now)— the wave-collapse.computeEstimate(ticketTypes, selection)— the greedy-fill.
Both are deterministic, side-effect-free, and tested in isolation on every SDK release.
Related
EstimateBreakdownAvailabilityListTicketRow— picker rowReservationSummary