Skip to content
Draft
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## Unreleased

Needs a runtime advertising `staff-map-v1` (not yet pinned by this package).

- **Staff map.** `SeatLayerConfiguration.manageAccessToken` and
`manageAccessTokenProvider` boot `SeatLayerView` with an event-scoped manage
grant (`mse_…`) instead of buyer access. The view then shows the organizer's
live board: staff pick seats, held and sold seats included, and the
selection arrives on `onSelectionChanged` with no hold placed.
`onStaffConnectionChanged` reports the live connection; `focusSection`,
`setUnavailableObjects` and `setCategoryPrices` drive the board. A renderer
without the capability fails the load with `SeatLayerError.incompatible`,
and a manage grant mixed with buyer access throws an `ArgumentError`.

## 0.12.0 (2026-09-25)

Runtime pin 0.101.0. No Dart API changes; the map inside the picker is newer.
Expand Down
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,46 @@ changes refused in the runtime *and* in the controller, which fails such a call
with a typed `read_only` error rather than relying on hidden UI. Continue with
[holds and secure server-side checkout](https://docs.seatlayer.io/buyer-sdk/holds-and-checkout/).

## Staff map (box office and door)

For an app used by an organizer's own staff, `SeatLayerView` can show the
organizer's live board instead of the buyer map. Staff pick seats on the live
map, held and sold seats included, and the selection arrives on
`onSelectionChanged`. Nothing is held while they pick; your app completes the
sale separately.

Boot it with an event-scoped manage grant (`mse_…`) instead of buyer access.
Your backend mints the grant for one event with the `event:view` capability
and `seatLayerMobileOrigin` (`https://cdn.seatlayer.io`) as its allowed origin.
Grants are short-lived, so give the view a provider that renews them:

```dart
SeatLayerView(
controller: controller,
configuration: SeatLayerConfiguration(
event: 'ev_xxx',
manageAccessTokenProvider: (context) async {
final grant = await myBackend.mintSeatMapGrant('ev_xxx');
return ManageAccessToken(token: grant.token, expiresAt: grant.expiresAt);
},
),
);

controller.onSelectionChanged.listen((seats) => sale.setSeats(seats));
controller.onGAClick.listen((area) => sale.askQuantity(area.id));
controller.onStaffConnectionChanged.listen((state) => showLive(state.isLive));
await controller.focusSection('Stalls'); // or null for the whole venue
```

- A manage grant cannot be combined with `publicKey` or buyer access; the
configuration throws an `ArgumentError`.
- The staff map runs in `SeatLayerView`, not `SeatLayerPicker`.
- It needs a renderer advertising `staff-map-v1`. On an older renderer the
load fails with `SeatLayerError.incompatible`.
- Staff map commands: `selectObjects`, `deselectObjects`, `clearSelection`,
`getSelection`, `zoomToFit`, `focusSection`, `setUnavailableObjects` and
`setCategoryPrices`. Hold commands answer `unsupported_command`.

## Widget catalogue

Every widget below works standalone inside a `SeatLayerPickerScope`.
Expand Down
1 change: 1 addition & 0 deletions lib/seatlayer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -183,3 +183,4 @@ export 'src/seat_layer_controller.dart';
export 'src/seat_layer_error.dart';
export 'src/seat_layer_prewarm.dart' show seatLayerPrewarmDefaultTtl;
export 'src/seat_layer_view.dart';
export 'src/staff_map.dart';
53 changes: 53 additions & 0 deletions lib/src/seat_layer_configuration.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'bridge/bridge_client.dart';
import 'bridge/bridge_protocol.dart';
import 'open_enums.dart';
import 'payloads.dart';
import 'staff_map.dart';

/// This SDK's version.
const String seatLayerSdkVersion = '0.12.0';
Expand Down Expand Up @@ -52,6 +53,8 @@ class SeatLayerConfiguration {
this.showsWebSeatTooltip = false,
this.buyerAccessToken,
this.buyerAccessTokenProvider,
this.manageAccessToken,
this.manageAccessTokenProvider,
this.selectedObjects,
this.selectableObjects,
this.numberOfPlacesToSelect,
Expand All @@ -76,6 +79,22 @@ class SeatLayerConfiguration {
buyerAccessToken!.expiresAt?.isFinite == false)) {
throw ArgumentError.value(buyerAccessToken, 'buyerAccessToken');
}
if (manageAccessToken != null && !manageAccessToken!.isWellFormed) {
throw ArgumentError.value(
manageAccessToken,
'manageAccessToken',
'must be an mse_ grant with a finite expiresAt',
);
}
if (usesManageAccess &&
(publicKey != null ||
buyerAccessToken != null ||
buyerAccessTokenProvider != null)) {
throw ArgumentError(
'A manage grant cannot be combined with buyer access '
'(publicKey, buyerAccessToken, buyerAccessTokenProvider).',
);
}
for (final validator
in selectionValidators ?? const <SelectionValidator>[]) {
if (validator is MinimumSelectedPlaces && validator.minimum < 1) {
Expand Down Expand Up @@ -132,6 +151,25 @@ class SeatLayerConfiguration {
/// Renews private buyer access in memory without rebuilding the view.
final BuyerAccessTokenProvider? buyerAccessTokenProvider;

/// An event-scoped manage grant (`mse_…`): the view shows the staff map
/// instead of the buyer map.
///
/// The staff map is the organizer's live board in pick mode. Staff pick
/// seats (held and sold seats included) and the selection arrives on
/// `SeatLayerController.onSelectionChanged`; nothing is held while they
/// pick. It cannot be combined with [publicKey], [buyerAccessToken] or
/// [buyerAccessTokenProvider], and it runs in `SeatLayerView` only.
///
/// Mint the grant for this event with the `event:view` capability and
/// [seatLayerMobileOrigin] as its allowed origin. Prefer
/// [manageAccessTokenProvider], which also renews it.
final ManageAccessToken? manageAccessToken;

/// Mints and renews the staff map's manage grant in memory. When
/// [manageAccessToken] is also set, it is used first and this renews it;
/// without one, this is also asked for the first grant.
final ManageAccessTokenProvider? manageAccessTokenProvider;

final List<String>? selectedObjects;
final List<String>? selectableObjects;
final int? numberOfPlacesToSelect;
Expand Down Expand Up @@ -180,6 +218,12 @@ class SeatLayerConfiguration {
config['buyerAccessToken'] = buyerAccessToken!.toJson();
}
if (buyerAccessTokenProvider != null) config['nativeAccessProvider'] = true;
if (manageAccessToken != null) {
config['manageAccessToken'] = manageAccessToken!.toJson();
}
if (manageAccessTokenProvider != null) {
config['manageAccessProvider'] = true;
}
if (selectedObjects != null) config['selectedObjects'] = selectedObjects;
if (selectableObjects != null) {
config['selectableObjects'] = selectableObjects;
Expand All @@ -203,6 +247,10 @@ class SeatLayerConfiguration {
bool get usesPrivateAccess =>
buyerAccessToken != null || buyerAccessTokenProvider != null;

/// Whether this configuration boots the staff map.
bool get usesManageAccess =>
manageAccessToken != null || manageAccessTokenProvider != null;

bool get usesSelectionPolicy =>
selectedObjects != null ||
selectableObjects != null ||
Expand All @@ -229,6 +277,11 @@ class SeatLayerConfiguration {
other.buyerAccessToken?.toJson(),
) &&
identical(buyerAccessTokenProvider, other.buyerAccessTokenProvider) &&
_deepEquals(
manageAccessToken?.toJson(),
other.manageAccessToken?.toJson(),
) &&
identical(manageAccessTokenProvider, other.manageAccessTokenProvider) &&
_deepEquals(selectedObjects, other.selectedObjects) &&
_deepEquals(selectableObjects, other.selectableObjects) &&
numberOfPlacesToSelect == other.numberOfPlacesToSelect &&
Expand Down
108 changes: 107 additions & 1 deletion lib/src/seat_layer_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'open_enums.dart';
import 'payloads.dart';
import 'seat_layer_configuration.dart';
import 'seat_layer_error.dart';
import 'staff_map.dart';

/// The public, `Future`- and `Stream`-based API for a seat map.
///
Expand Down Expand Up @@ -70,6 +71,8 @@ class SeatLayerController {
final _onDeckTap = StreamController<String>.broadcast();
final _onUnknownEvent = StreamController<UnknownEvent>.broadcast();
final _onBridgeEvent = StreamController<EventSignal>.broadcast();
final _onStaffConnectionChanged =
StreamController<StaffConnectionState>.broadcast();

// MARK: - State

Expand Down Expand Up @@ -141,6 +144,11 @@ class SeatLayerController {
/// An event this build does not model — a bundle newer than the app.
Stream<UnknownEvent> get onUnknownEvent => _onUnknownEvent.stream;

/// Staff map only: the live connection went up or down, or ended because
/// the manage grant could not be renewed. See [StaffConnectionState].
Stream<StaffConnectionState> get onStaffConnectionChanged =>
_onStaffConnectionChanged.stream;

/// Every accepted bridge event, before raw-chart routing.
///
/// Used by the high-level picker adapter so protocol-v2 events do not have to
Expand Down Expand Up @@ -262,6 +270,31 @@ class SeatLayerController {
);
return;
}
if (config.usesManageAccess) {
if (profile.isPicker) {
_finishHandshake(
const SeatLayerError.bridge(
BridgeErrorPayload(
code: 'bad_payload',
message: 'The staff map runs in SeatLayerView, not in '
'SeatLayerPicker.',
),
),
);
return;
}
if (!info.supportsCapability(seatLayerStaffMapCapability)) {
_finishHandshake(
SeatLayerError.incompatible(
native: profile.protocolRange,
web: info.protocolRange,
reason:
'the bundle does not support the staff map ($seatLayerStaffMapCapability)',
),
);
return;
}
}
if (config.usesPrivateAccess &&
!info.supportsCapability('native-access-provider')) {
_finishHandshake(
Expand Down Expand Up @@ -343,7 +376,14 @@ class SeatLayerController {
final maximum = jIntLocal(jGetLocal(payload, 'maxSelection'));
if (maximum != null) _onSelectionLimit.add(maximum);
case 'access.token.request':
unawaited(_provideBuyerAccessToken(payload));
unawaited(
_configuration?.usesManageAccess == true
? _provideManageAccessToken(payload)
: _provideBuyerAccessToken(payload),
);
case 'staff.connection':
final state = StaffConnectionState.fromJson(payload);
if (state != null) _onStaffConnectionChanged.add(state);
case 'access.expired':
final event = BuyerAccessExpiredEvent.fromJson(payload);
if (event != null) _onBuyerAccessExpired.add(event);
Expand Down Expand Up @@ -436,6 +476,47 @@ class SeatLayerController {
}
}

Future<void> _provideManageAccessToken(Object? payload) async {
final requestId = jStrLocal(jGetLocal(payload, 'requestId'));
if (requestId == null) return;
final client = _client;
if (client == null) return;
final rawReason = jStrLocal(jGetLocal(payload, 'reason')) ?? 'initial';
final provider = _configuration?.manageAccessTokenProvider;

Future<void> unavailable() async {
try {
await client.command(
'access.token.unavailable',
payload: {'requestId': requestId},
);
} catch (_) {}
}

if (provider == null) return unavailable();
ManageAccessToken token;
try {
token = await provider(
ManageAccessRequestContext(
reason: ManageAccessRefreshReason.fromRaw(rawReason),
),
);
if (!token.isWellFormed) throw StateError('invalid manage grant');
} catch (_) {
// Provider failures are sanitized; errors and grants never become events.
return unavailable();
}

try {
await client.command(
'access.token.provide',
payload: {'requestId': requestId, ...token.toJson()},
);
} catch (_) {
// The request may have timed out or the view may have reloaded.
}
}

void _finishHandshake(Object outcome) {
if (_disposed) return;
if (_hasFinished) {
Expand Down Expand Up @@ -638,6 +719,30 @@ class SeatLayerController {
);
}

/// Staff map only: frame one section by its public label, or the whole
/// venue for `null`. Returns whether the label matched a section.
Future<bool> focusSection(String? label) async {
final result = await _run('staff.focusSection', {'label': label});
return jBoolLocal(jGetLocal(result, 'found')) ?? false;
}

/// Staff map only: show these seats as unavailable although the event has
/// them free, and refuse to pick them. [reason] is the word the map's hover
/// shows. Display only; replaces the previous list.
Future<void> setUnavailableObjects(List<String> objects, {String? reason}) =>
_run('staff.setUnavailableObjects', {
'objects': objects,
if (reason != null) 'reason': reason,
});

/// Staff map only: the price the map's hover shows per ticket category, for
/// a sale at a price the event does not carry. `null` shows no price; a
/// category left out keeps its own. Display only.
Future<void> setCategoryPrices(Map<String, StaffCategoryPrice>? prices) =>
_run('staff.setCategoryPrices', {
'prices': prices?.map((key, price) => MapEntry(key, price.toJson())),
});

Future<void> zoomIn() => _run('zoomIn');
Future<void> zoomOut() => _run('zoomOut');
Future<void> zoomToFit() => _run('zoomToFit');
Expand Down Expand Up @@ -677,6 +782,7 @@ class SeatLayerController {
_onDeckTap.close();
_onUnknownEvent.close();
_onBridgeEvent.close();
_onStaffConnectionChanged.close();
}

List<T> _decodeList<T>(Object? value, T? Function(Object?) decode) {
Expand Down
Loading
Loading