Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 29 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
[![TypeScript](https://img.shields.io/badge/TypeScript-types%20included-3178C6.svg)](https://www.typescriptlang.org/)
[![License: MIT](https://img.shields.io/badge/license-MIT-111827.svg)](LICENSE)

SeatLayer is interactive seating chart software built for stadium scale. Platforms embed the white-label seat picker with their own checkout; organizers sell seated events on their own website with their own payment gateway.

The official SeatLayer React Native and Expo SDK adds an interactive seating
chart and seat picker to iOS and Android ticketing apps. It combines live
The official SeatLayer React Native seat map SDK adds an interactive seating
chart and seat picker to React Native and Expo ticketing apps on iOS and Android. It combines live
availability, best-available selection, temporary holds, and typed TypeScript
APIs with composable React Native buyer controls around SeatLayer's
version-pinned shared venue renderer; your trusted server completes booking.
SeatLayer is seating chart and reserved-seat ticketing software built for
venues up to stadium scale.

[`@seatlayer/react-native` on npm](https://www.npmjs.com/package/@seatlayer/react-native) ·
[React Native seat-map documentation](https://docs.seatlayer.io/buyer-sdk/react-native/) ·
Expand Down Expand Up @@ -64,7 +64,7 @@ ships no custom native module of its own, so there is nothing else to link.

Peer requirements are `react >= 18.2.0`, `react-native >= 0.72.0`, and
`react-native-webview >= 13.0.0`. TypeScript declarations are published with the
package — `dist/index.d.ts` for ESM and `dist/index.d.cts` for CommonJS — so no
package (`dist/index.d.ts` for ESM and `dist/index.d.cts` for CommonJS), so no
`@types/*` package is needed.

## Quick start: ready-made seat picker
Expand Down Expand Up @@ -277,8 +277,8 @@ The customization layers have distinct ownership:
`defaultChild`. Use a builder when structure or placement must change.

An app that wants the picker set in its own typeface passes
`themeOptions.theme.fontFamily` and loads the face itself — with `expo-font`,
`react-native.config.js` asset linking, or whatever the app already uses — and
`themeOptions.theme.fontFamily` and loads the face itself (with `expo-font`,
`react-native.config.js` asset linking, or whatever the app already uses), and
every line the picker draws then takes that family. Without it the picker draws
the platform's own face, which is the right default: React Native has no
inherited text style, so a picker that guessed at the surrounding typography
Expand Down Expand Up @@ -311,7 +311,7 @@ Callbacks are optional observations, never a place the SDK waits:
`onThemeResolved` · `onSectionFocused` · `onSeatSelected` · `onSeatRemoved` ·
`onSeatViewOpened` · `onSeatConfidence` · `onContinue`

`onBooked` fires once, when the handed-off hold settles to booked — never on the
`onBooked` fires once, when the handed-off hold settles to booked, never on the
handoff itself, because a buyer on the way to pay has not paid.
`onSeatConfidence` opens the seat's confidence passport; supplying it turns the
3D card's confidence teaser into a chip, and without it the teaser stays a
Expand Down Expand Up @@ -390,9 +390,9 @@ venue-map ones. Reach it as `controller` from `useSeatLayerPicker()` inside a
| --- | --- |
| `setSelectionFocus(seatId \| null)` | Names the seat a host-drawn card is asking about, so the runtime paints it as the candidate. `null` clears the paint. |
| `setBlockedRegions(regions \| null)` | Reports where native chrome lies over the map, in the map's own pixels, so the runtime swallows a touch that starts inside one. |
| `frameSeat(seatId, options)` | Pans — never zooms — so a seat rests in the band the reported insets leave clear. Answers `dy: 0` for a seat already in place, an unknown seat, insets that leave no band, or a stale gesture count. |
| `frameSeat(seatId, options)` | Pans (never zooms) so a seat rests in the band the reported insets leave clear. Answers `dy: 0` for a seat already in place, an unknown seat, insets that leave no band, or a stale gesture count. |
| `focusAccessibilityFilter()` | Flies to the matches of the filter already on, leaving the filter alone. |
| `focusNextAccessibleSection(types?)` | Steps to the next section holding a free matching space, in chart order, wrapping. `null` — nothing matches — is an answer; `undefined` means the runtime does not offer the tour. |
| `focusNextAccessibleSection(types?)` | Steps to the next section holding a free matching space, in chart order, wrapping. `null` (nothing matches) is an answer; `undefined` means the runtime does not offer the tour. |
| `subscribeSeatRetap(listener)` | A seat already in the selection tapped again. Subscribing never replays an earlier event. |
| `subscribeBooked(listener)` | Fires once per sale, with the handoff that became it. |
| `getCheckoutHandoff()` / `getBookedHandoff()` | The handoff this picker made, and the one whose hold settled to booked. |
Expand All @@ -419,7 +419,7 @@ guarding after its control has gone.
While the seat card is up, the map goes behind a veil with a feathered hole
around the tapped seat, so the buyer can still see the seat they are being
asked about. React Native has no blur of its own, and a native blur module
cannot be a hard dependency — a bundler resolves `require` statically, so an
cannot be a hard dependency: a bundler resolves `require` statically, so an
app without the module could not build.

An app that already has one installs it once, at start-up:
Expand All @@ -438,21 +438,21 @@ transparency.

## Optional vector seat glyphs

A seat can say what it is — an accessible physical seat, an empty wheelchair
space, a restricted view, a premium seat — and each of those wears a drawing
A seat can say what it is (an accessible physical seat, an empty wheelchair
space, a restricted view, a premium seat), and each of those wears a drawing
shared with every other SeatLayer SDK, so the same seat wears the same mark
everywhere. With no drawing dependency the picker hands each glyph to `Image`
as an SVG data URI.

**On iOS that fallback draws nothing.** `Image` decodes a data URI through the
platform's own image decoders, and none of them reads SVG, so the seat-note
bands on the seat card and the rows of the accessibility sheet keep their words
and lose the mark beside them. Nothing else changes — no gap, no broken box,
no error — but if you want the marks on iOS, install the renderer below.
and lose the mark beside them. Nothing else changes (no gap, no broken box,
no error), but if you want the marks on iOS, install the renderer below.

`react-native-svg` is an **optional peer**. An app that already has it installs
the built-in vector renderer once, at start-up, and every glyph is drawn
natively instead — scaling without resampling and taking each row's ink
natively instead, scaling without resampling and taking each row's ink
directly:

```tsx
Expand All @@ -465,7 +465,7 @@ installSeatLayerPickerSvgIcons({ Svg, Path, Circle });
You pass your own imports rather than the SDK requiring the module, for the
same reason as the blur above: a bundler resolves `require` statically, so a
guarded import here would put `react-native-svg` in every consumer's build
graph whether they wanted it or not. `Circle` is optional — without it the
graph whether they wanted it or not. `Circle` is optional; without it the
circles in a glyph are drawn as paths. Pass `undefined` to go back to the data
URI, and `setSeatLayerPickerSeatIconRenderer` to draw them some other way
entirely.
Expand Down Expand Up @@ -526,9 +526,9 @@ TypeScript controller whose contract matches the Web, iOS, and Flutter SDKs:
See [the bridge contract](docs/bridge.md) for the wire-level details.

A host may warm the runtime page before the picker is opened, with
`SeatLayerRuntimePrewarm`. On React Native this warms the *transport* — DNS,
`SeatLayerRuntimePrewarm`. On React Native this warms the *transport* (DNS,
TLS, the CDN edge and the HTTP cache entry for the runtime document and, where
named, its bundles — rather than a live page, because a page belongs to the
named, its bundles) rather than a live page, because a page belongs to the
component that rendered it. The warm entry lives on a short TTL and is dropped
under memory pressure, and a picker that finds nothing warm simply starts cold.

Expand Down Expand Up @@ -589,19 +589,19 @@ regions and focus handling. Two of those need something from the host app.

### iOS reading order needs a native feature flag

The picker walks a screen reader through the seat map in buyer order — event,
prices, map, then the tray — rather than in the order the views happen to be
The picker walks a screen reader through the seat map in buyer order (event,
prices, map, then the tray) rather than in the order the views happen to be
painted. It declares that with React Native's own
`experimental_accessibilityOrder`, naming the `nativeID` of each surface at the
composition root.

**On iOS that prop is only honoured when the app turns the native feature flag
on.** Without it, nothing breaks and nothing is announced twice — VoiceOver
on.** Without it, nothing breaks and nothing is announced twice: VoiceOver
simply falls back to the paint order, in which the cart tray is reached before
the map. On Android the order is honoured without a flag.

Turn it on once, early in the app's native start-up, before the first React
Native view is created — in `AppDelegate`:
Native view is created, in `AppDelegate`:

```objc
// AppDelegate.mm, above [super application:didFinishLaunchingWithOptions:]
Expand All @@ -620,7 +620,7 @@ Expo apps reach the same file through a config plugin or a prebuild; a managed
project that cannot run native code does not get the declared order, and the
picker stays usable on the fallback.

Check your React Native version's release notes for the flag's exact name — it
Check your React Native version's release notes for the flag's exact name: it
has been an experimental API, and the SDK deliberately spreads the prop rather
than typing it, so a runtime that does not know it simply ignores it.

Expand All @@ -640,7 +640,7 @@ settings:

- the map is one named region with a hint naming the controls around it;
- the seat card is a dialog, with custom actions, that hides the page beneath
it, and focus returns to the map when the card — or a toast's action — is
it, and focus returns to the map when the card (or a toast's action) is
done with it;
- a change that is news is a live region and nothing else is: where the buyer
has arrived and how much room is left there, and the hold's countdown;
Expand Down Expand Up @@ -671,7 +671,7 @@ covers lifecycle, commands, and events in depth.

`SeatLayerView` is a React Native component with a typed TypeScript controller
and a SeatLayer venue-map renderer. The package contains no custom native
module — no podspec, no Java, Kotlin, Swift, or Objective-C source — so
module (no podspec, no Java, Kotlin, Swift, or Objective-C source), so
application code works through TypeScript commands, payloads, errors, and
events.

Expand All @@ -687,16 +687,16 @@ app that renders the SDK.

When a buyer selects seats, the SDK creates a temporary hold that reserves the
inventory against concurrent buyers for a limited window. The hold expires
automatically if checkout does not complete — the `holdExpired` event tells the
app to return the buyer to the map — and `extendHold` and `resumeHold` cover
automatically if checkout does not complete (the `holdExpired` event tells the
app to return the buyer to the map), and `extendHold` and `resumeHold` cover
longer checkouts and app restarts. This prevents double-selling without locking
seats forever.

### Can I use my own payment provider?

Yes. SeatLayer never processes payment inside the seat map. The app hands the
`holdId` to your backend, and your backend charges through any payment provider
you already use — Stripe, Adyen, Razorpay, or your own — before booking the hold
you already use (Stripe, Adyen, Razorpay, or your own) before booking the hold
through the
[server-side checkout flow](https://docs.seatlayer.io/buyer-sdk/holds-and-checkout/).

Expand Down
25 changes: 12 additions & 13 deletions design/picker-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,8 @@ gate them, and the reference file.

### 3.1 Header

The header's hold pill is drawn for as long as a hold is live (owner call,
2026-09-05): it is the picker's ONE clock. There is no hold clock anywhere on
The header's hold pill is drawn for as long as a hold is live: it is the
picker's ONE clock. There is no hold clock anywhere on
the cart sheet, in either state, and no "display buffer" on it — the pill shows
the full server hold. This deliberately departs from the web's
`data-peek-clock` rule, under which the pill stepped aside while a peek bar
Expand Down Expand Up @@ -265,13 +265,13 @@ is the identity.
`size.minimumHitTarget` centred on it. Copy `strings.close`.

**Hold pill.** Drawn for as long as a live hold exists, whoever owns it
(owner call 2026-09-05: the clock is always in the header; a host that draws
(the clock is always in the header; a host that draws
its own clock turns it off with the `showHoldPill` option). A true pill (`radius.pill`), a
dot and `m:ss` in `type.pill`, tabular figures, with a `size.minimumHitTarget`
reach. Resting look is the accent mixed lightly into the surface with accent-
toned ink; while expiring it inverts to the full accent with `color.*.onAccent`
and its dot pulses. Copy `strings.heldFor`. It is never hidden while the hold
lives — the countdown is said once, here (owner call, 2026-09-05).
lives: the countdown is said once, here.

**Sales-closed pill.** Same pill geometry, deliberately neutral — the text
colour on a lightly text-tinted surface, never the accent. Copy
Expand Down Expand Up @@ -482,7 +482,7 @@ inset `size.mapAnchorInset` from the map's edges, with `size.mapAnchorGap`
between members. Regions do not receive presses; their children do. Nothing
free-floats.

**One column, bottom-right, on both compositions** (owner call, 2026-09-06).
**One column, bottom-right, on both compositions.**
The ♿ filter disc heads it; the zoom discs follow. It used to stand alone in
the bottom-left region — one control facing a stack of them, in the corner the
floor rail already owns — and read as something the layout had forgotten. Who
Expand All @@ -502,7 +502,7 @@ Every disc is `size.mapControlSize` across, the ♿ disc
`size.zoomColumnGap`; the head disc's stepper rides beside it at
`size.accessStepGap` (§3.4.1).

**There is no `−` disc on the phone** (owner call, 2026-09-06). It only ever
**There is no `−` disc on the phone.** It only ever
had a rung of its own while the buyer was already among the seats: at a
section's own frame the one step back is the whole venue, and that is the disc
below it. Two discs for one move reads as a puzzle, and a disc that only
Expand Down Expand Up @@ -1625,8 +1625,7 @@ Full entry: `components.md` › HoverCard.
**Name** `SeatLayerCartSheet` · **slots** `sheetStyle`, `continueButtonStyle`
· **file** `lib/src/picker/picker_cart_sheet.dart`

**THE COLLAPSED SHEET IS THE FOOTER BLOCK, AND NOTHING ELSE** (owner call
2026-09-06): the total line, the call to action and the by-line, in one set of
**THE COLLAPSED SHEET IS THE FOOTER BLOCK, AND NOTHING ELSE**: the total line, the call to action and the by-line, in one set of
rules shared with the wide panel. **The cards wait behind the handle.** A list
that unrolled itself every time a seat was added read as a panel the buyer had
not opened, so the collapsed cart region has height zero (`_collapsedCart = 0`)
Expand Down Expand Up @@ -1764,8 +1763,8 @@ seat (§3.10.2) leaves the sheet where the buyer put it.
**Name** `SeatLayerCartList` / `SeatLayerCartCard` ·
**file** `lib/src/picker/picker_cart_list.dart`

ONE CARD PER TICKET, AND THE SAME CARD ON EVERY WIDTH (owner call 2026-09-06:
"cards should be the same design as desktop"). The phone used to draw a second
ONE CARD PER TICKET, AND THE SAME CARD ON EVERY WIDTH: the phone draws the same
card design as desktop. The phone used to draw a second
cart — a bordered plate of `44` pt hairline-divided lines, with consecutive
seats folded into runs behind a `+N more`. It saved real pixels and it cost the
sheet its coherence: a bordered list on its own surface between a band of
Expand Down Expand Up @@ -2358,7 +2357,7 @@ stepper, a range caption `strings.chooseMinMaxGuests`, and the action pair
`strings.fewerGuests` / `strings.moreGuests`. Accessible name for the whole
sheet: `strings.chooseTableGuests`. Capability `table-quantity-v1`.

#### 3.13.13 Seats already in checkout (N1, owner decision 2026-09-05: option B)
#### 3.13.13 Seats already in checkout

The hold belongs to the host from the moment it is handed off, and the runtime
refuses to grow or shrink it from the picker: `hold_owned_by_host` (a cart
Expand Down Expand Up @@ -2728,7 +2727,7 @@ from the wrong one.

## 5. Capability index

| Capability | What it unlocks |
| Capability | What it enables |
| --- | --- |
| `native-chrome-contract-v1` | the runtime suppresses its own buyer chrome; native owns header, rail, dock, tray, prompts |
| `viewport-insets-v1` | native reports the bands its chrome covers, so framing lands inside them |
Expand Down Expand Up @@ -2757,7 +2756,7 @@ snapshot reports; their presence in the `hello` **command table** is the whole
contract, and a runtime answering `unsupported_command` for one of them anyway
leaves nothing on screen for the buyer to read (runtime 0.80.2+):

| Command | What it unlocks |
| Command | What it enables |
| --- | --- |
| `picker.setSelectionFocus { seatId \| null }` | the seat a card is asking about is painted as the candidate — thick double ring, halo, neighbours paled (§3.8.2) |
| `picker.setBlockedRegions { rects }` | the runtime's own tap guard under native chrome over the map (§2.4) |
Expand Down
2 changes: 1 addition & 1 deletion src/picker/SeatLayerCartList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ export function SeatLayerCartList(props: SeatLayerCartListProps): React.ReactEle
/**
* The map frames the seat at its resting place, and the SHEET STAYS OPEN.
*
* Owner call, carried on both platforms: stepping the sheet down as well
* The same on both platforms: stepping the sheet down as well
* answered a question the buyer had not asked. A tap on a cart card means
* "where is this one?", and closing the list they were reading through to
* answer it made checking a second seat cost a re-open every time. The card
Expand Down
2 changes: 1 addition & 1 deletion src/picker/SeatLayerDockBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ function MeasureText({
* Where the buyer is, and the two ways out, docked under the map. Rung 2 only,
* sliding away when the map climbs a level.
*
* THE DROP-IN MOUNTS NO DOCK ON ANY WIDTH (§3.6, owner call 2026-09-06): the
* THE DROP-IN MOUNTS NO DOCK ON ANY WIDTH (§3.6): the
* pinch and the single stepped `-` control already walk a buyer back to the
* venue, and the bar's height plus the home-indicator inset pushed every
* bottom-corner control up the screen. A host that wants it sets
Expand Down
Loading
Loading