From 26b85da1ca0de7cde1a33aa056f6be74f9713ec1 Mon Sep 17 00:00:00 2001 From: navin10sharma <3096611+navin10sharma@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:56:01 +0530 Subject: [PATCH 1/2] Staff map: boot SeatLayerView with a manage grant SeatLayerConfiguration gains manageAccessToken and manageAccessTokenProvider. 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. The handshake requires staff-map-v1 and fails with SeatLayerError.incompatible without it. A grant that is not mse_, or one mixed with buyer access, throws an ArgumentError; the picker surface is refused. --- lib/seatlayer.dart | 1 + lib/src/seat_layer_configuration.dart | 53 ++++ lib/src/seat_layer_controller.dart | 108 +++++++- lib/src/staff_map.dart | 114 ++++++++ test/staff_map_test.dart | 379 ++++++++++++++++++++++++++ 5 files changed, 654 insertions(+), 1 deletion(-) create mode 100644 lib/src/staff_map.dart create mode 100644 test/staff_map_test.dart diff --git a/lib/seatlayer.dart b/lib/seatlayer.dart index c922713..6e2b8ba 100644 --- a/lib/seatlayer.dart +++ b/lib/seatlayer.dart @@ -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'; diff --git a/lib/src/seat_layer_configuration.dart b/lib/src/seat_layer_configuration.dart index 5f3fa1c..9fcbb07 100644 --- a/lib/src/seat_layer_configuration.dart +++ b/lib/src/seat_layer_configuration.dart @@ -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'; @@ -52,6 +53,8 @@ class SeatLayerConfiguration { this.showsWebSeatTooltip = false, this.buyerAccessToken, this.buyerAccessTokenProvider, + this.manageAccessToken, + this.manageAccessTokenProvider, this.selectedObjects, this.selectableObjects, this.numberOfPlacesToSelect, @@ -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 []) { if (validator is MinimumSelectedPlaces && validator.minimum < 1) { @@ -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? selectedObjects; final List? selectableObjects; final int? numberOfPlacesToSelect; @@ -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; @@ -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 || @@ -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 && diff --git a/lib/src/seat_layer_controller.dart b/lib/src/seat_layer_controller.dart index 4887627..492c0a5 100644 --- a/lib/src/seat_layer_controller.dart +++ b/lib/src/seat_layer_controller.dart @@ -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. /// @@ -70,6 +71,8 @@ class SeatLayerController { final _onDeckTap = StreamController.broadcast(); final _onUnknownEvent = StreamController.broadcast(); final _onBridgeEvent = StreamController.broadcast(); + final _onStaffConnectionChanged = + StreamController.broadcast(); // MARK: - State @@ -141,6 +144,11 @@ class SeatLayerController { /// An event this build does not model — a bundle newer than the app. Stream 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 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 @@ -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( @@ -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); @@ -436,6 +476,47 @@ class SeatLayerController { } } + Future _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 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) { @@ -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 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 setUnavailableObjects(List 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 setCategoryPrices(Map? prices) => + _run('staff.setCategoryPrices', { + 'prices': prices?.map((key, price) => MapEntry(key, price.toJson())), + }); + Future zoomIn() => _run('zoomIn'); Future zoomOut() => _run('zoomOut'); Future zoomToFit() => _run('zoomToFit'); @@ -677,6 +782,7 @@ class SeatLayerController { _onDeckTap.close(); _onUnknownEvent.close(); _onBridgeEvent.close(); + _onStaffConnectionChanged.close(); } List _decodeList(Object? value, T? Function(Object?) decode) { diff --git a/lib/src/staff_map.dart b/lib/src/staff_map.dart new file mode 100644 index 0000000..2e14f10 --- /dev/null +++ b/lib/src/staff_map.dart @@ -0,0 +1,114 @@ +import 'dart:async'; + +/// The renderer capability that runs the staff map. +/// +/// The view checks it during the handshake; a renderer without it fails the +/// load with `SeatLayerError.incompatible` rather than showing a buyer map. +const String seatLayerStaffMapCapability = 'staff-map-v1'; + +/// An event-scoped manage grant for the staff map. +/// +/// Your backend mints it for one event with the `event:view` capability and +/// `seatLayerMobileOrigin` (`https://cdn.seatlayer.io`) as its allowed origin, +/// the origin the map runs on. +/// A grant is short-lived: supply a [ManageAccessTokenProvider] so the map +/// can renew it without reloading. +class ManageAccessToken { + const ManageAccessToken({required this.token, this.expiresAt}); + + /// The grant, `mse_…`. + final String token; + + /// Epoch milliseconds. Omit to renew reactively. + final double? expiresAt; + + /// Whether this looks like a manage grant: an `mse_` prefix and a finite + /// expiry when one is given. + bool get isWellFormed => + RegExp(r'^mse_[A-Za-z0-9]+$').hasMatch(token) && + (expiresAt == null || expiresAt!.isFinite); + + Map toJson() => { + 'token': token, + if (expiresAt != null) 'expiresAt': expiresAt, + }; +} + +/// Why the staff map needs a manage grant. +class ManageAccessRefreshReason { + const ManageAccessRefreshReason(this.raw); + final String raw; + + /// The map is starting and no [ManageAccessToken] was configured. + static const initial = ManageAccessRefreshReason('initial'); + + /// The current grant is about to expire, or was refused. + static const refresh = ManageAccessRefreshReason('refresh'); + + factory ManageAccessRefreshReason.fromRaw(String raw) => switch (raw) { + 'initial' => initial, + 'refresh' => refresh, + _ => ManageAccessRefreshReason(raw), + }; + + @override + bool operator ==(Object other) => + other is ManageAccessRefreshReason && other.raw == raw; + + @override + int get hashCode => raw.hashCode; +} + +class ManageAccessRequestContext { + const ManageAccessRequestContext({required this.reason}); + final ManageAccessRefreshReason reason; +} + +/// Mints or renews the staff map's manage grant, in memory. +typedef ManageAccessTokenProvider = FutureOr Function( + ManageAccessRequestContext context, +); + +/// The staff map's live connection, as reported by +/// `SeatLayerController.onStaffConnectionChanged`. +class StaffConnectionState { + const StaffConnectionState({required this.status, this.lastMessageAt}); + + /// `live`, `reconnecting`, or `ended` once the grant could not be renewed. + /// An open set: treat anything else like `reconnecting`. + final String status; + + /// When the map last received an update, epoch milliseconds; `null` before + /// the first one. The honest "as of" for what the map is showing. + final int? lastMessageAt; + + bool get isLive => status == 'live'; + bool get hasEnded => status == 'ended'; + + static StaffConnectionState? fromJson(Object? value) { + if (value is! Map) return null; + final status = value['status']; + if (status is! String) return null; + final at = value['lastMessageAt']; + return StaffConnectionState( + status: status, + lastMessageAt: at is num ? at.toInt() : null, + ); + } +} + +/// The price the staff map's hover shows for one ticket category: an amount, +/// or a from–to range. Display only. +class StaffCategoryPrice { + const StaffCategoryPrice.amount(num this.amount) + : min = null, + max = null; + const StaffCategoryPrice.range({required num this.min, required num this.max}) + : amount = null; + + final num? amount; + final num? min; + final num? max; + + Object toJson() => amount ?? {'min': min, 'max': max}; +} diff --git a/test/staff_map_test.dart b/test/staff_map_test.dart new file mode 100644 index 0000000..9c567b3 --- /dev/null +++ b/test/staff_map_test.dart @@ -0,0 +1,379 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:seatlayer/seatlayer.dart'; +import 'package:seatlayer/src/bridge/bridge_profile.dart'; + +final class _Channel implements BridgeChannel { + final sent = []; + + @override + Future send(Envelope envelope) async => sent.add(envelope); + + Iterable commands(String type) => + sent.where((e) => e.kind == EnvelopeKind.cmd && e.type == type); +} + +const _grant = ManageAccessToken(token: 'mse_0123abcd', expiresAt: 9000); + +Map _hello({bool staffMap = true}) => { + 'bundle': 'test', + 'protocol': {'min': 1, 'max': 2}, + 'capabilities': [ + 'native-access-provider', + if (staffMap) seatLayerStaffMapCapability, + ], + 'events': ['sys.ready'], + 'commands': [], + }; + +void _ingest(SeatLayerController controller, Envelope envelope) => + controller.ingestRaw(envelope.encode()); + +void _sendHello(SeatLayerController controller, {bool staffMap = true}) => + _ingest( + controller, + Envelope( + kind: EnvelopeKind.hello, + type: 'hello', + payload: _hello(staffMap: staffMap), + ), + ); + +void _event(SeatLayerController controller, String type, Object? payload, + int sequence) => + _ingest( + controller, + Envelope( + kind: EnvelopeKind.evt, + type: type, + sequence: sequence, + payload: payload, + ), + ); + +void _reply(SeatLayerController controller, Envelope command, + [Object? payload]) => + _ingest( + controller, + Envelope( + kind: EnvelopeKind.res, + type: command.type, + id: command.id, + payload: payload ?? const {}, + ), + ); + +void main() { + group('configuration', () { + test('sends the grant and the renewal flag, and no buyer access', () { + final config = SeatLayerConfiguration( + event: 'ev_staff', + manageAccessToken: _grant, + manageAccessTokenProvider: (_) => _grant, + ); + final payload = config.initPayload()['config']! as Map; + expect(payload['manageAccessToken'], + {'token': 'mse_0123abcd', 'expiresAt': 9000}); + expect(payload['manageAccessProvider'], true); + expect(payload.containsKey('publicKey'), isFalse); + expect(payload.containsKey('nativeAccessProvider'), isFalse); + expect(config.usesManageAccess, isTrue); + expect(config.usesPrivateAccess, isFalse); + }); + + test('a provider alone is enough', () { + final config = SeatLayerConfiguration( + event: 'ev_staff', + manageAccessTokenProvider: (_) => _grant, + ); + final payload = config.initPayload()['config']! as Map; + expect(payload.containsKey('manageAccessToken'), isFalse); + expect(payload['manageAccessProvider'], true); + }); + + test('refuses anything that is not an mse_ grant', () { + for (final token in [ + const ManageAccessToken(token: 'bse_buyer'), + const ManageAccessToken(token: 'sk_live_secret'), + const ManageAccessToken(token: ''), + const ManageAccessToken(token: 'mse_ok', expiresAt: double.infinity), + ]) { + expect( + () => SeatLayerConfiguration( + event: 'ev_staff', manageAccessToken: token), + throwsArgumentError, + reason: token.token, + ); + } + }); + + test('refuses a grant mixed with buyer access', () { + expect( + () => SeatLayerConfiguration( + event: 'ev_staff', + publicKey: 'pk_live_x', + manageAccessToken: _grant, + ), + throwsArgumentError, + ); + expect( + () => SeatLayerConfiguration( + event: 'ev_staff', + buyerAccessTokenProvider: (_) => + const BuyerAccessToken(token: 'bse_x'), + manageAccessTokenProvider: (_) => _grant, + ), + throwsArgumentError, + ); + }); + + test('a rebuilt configuration with the same grant is the same map', () { + SeatLayerConfiguration build() => + SeatLayerConfiguration(event: 'ev_staff', manageAccessToken: _grant); + expect(build().semanticallyEquals(build()), isTrue); + expect( + build().semanticallyEquals(SeatLayerConfiguration( + event: 'ev_staff', + manageAccessToken: const ManageAccessToken(token: 'mse_other'), + )), + isFalse, + ); + }); + }); + + group('handshake', () { + test('boots the staff map on a renderer that has it', () async { + final controller = SeatLayerController(); + addTearDown(controller.dispose); + final channel = _Channel(); + final ready = controller.beginHandshake( + channel, + SeatLayerConfiguration(event: 'ev_staff', manageAccessToken: _grant), + ); + _sendHello(controller); + await pumpEventQueue(); + final init = channel.sent.singleWhere((e) => e.kind == EnvelopeKind.init); + final config = (init.payload! as Map)['config'] as Map; + expect(config['manageAccessToken'], isA>()); + + _event( + controller, + 'sys.ready', + { + 'protocol': 1, + 'mode': 'live', + 'transport': 'flutter', + 'chart': {'event': 'ev_staff'}, + 'surface': {'kind': 'staff'}, + }, + 1, + ); + expect((await ready).eventKey, 'ev_staff'); + }); + + test('fails clearly on a renderer without the staff map', () async { + final controller = SeatLayerController(); + addTearDown(controller.dispose); + final channel = _Channel(); + final ready = controller.beginHandshake( + channel, + SeatLayerConfiguration(event: 'ev_staff', manageAccessToken: _grant), + ); + _sendHello(controller, staffMap: false); + await expectLater( + ready, + throwsA(isA().having( + (error) => error.reason, + 'reason', + contains(seatLayerStaffMapCapability), + )), + ); + expect(channel.sent.where((e) => e.kind == EnvelopeKind.init), isEmpty); + }); + + test('refuses the buyer picker surface', () async { + final controller = SeatLayerController(); + addTearDown(controller.dispose); + final channel = _Channel(); + final ready = controller.beginHandshake( + channel, + SeatLayerConfiguration(event: 'ev_staff', manageAccessToken: _grant), + profile: SeatLayerBridgeProfile.picker(), + ); + _ingest( + controller, + Envelope( + kind: EnvelopeKind.hello, + type: 'hello', + payload: { + ..._hello(), + 'protocol': {'min': 2, 'max': 2}, + 'capabilities': [ + ...SeatLayerBridgeProfile.picker().requiredCapabilities, + seatLayerStaffMapCapability, + ], + }, + ), + ); + await expectLater( + ready, + throwsA(isA() + .having((error) => error.code, 'code', 'bad_payload')), + ); + }); + }); + + group('while running', () { + Future<(SeatLayerController, _Channel)> running( + SeatLayerConfiguration config, + ) async { + final controller = SeatLayerController(); + addTearDown(controller.dispose); + final channel = _Channel(); + final ready = controller.beginHandshake(channel, config); + _sendHello(controller); + await pumpEventQueue(); + _event( + controller, + 'sys.ready', + { + 'protocol': 1, + 'mode': 'test', + 'transport': 'flutter', + 'chart': {'event': 'ev_staff'}, + }, + 1, + ); + await ready; + return (controller, channel); + } + + test('renews the grant through the provider, with its reason', () async { + final reasons = []; + final (controller, channel) = await running(SeatLayerConfiguration( + event: 'ev_staff', + manageAccessToken: _grant, + manageAccessTokenProvider: (context) { + reasons.add(context.reason); + return const ManageAccessToken(token: 'mse_next', expiresAt: 12000); + }, + )); + _event( + controller, + 'access.token.request', + {'requestId': 'access-1', 'reason': 'refresh'}, + 2, + ); + await pumpEventQueue(); + expect(reasons, [ManageAccessRefreshReason.refresh]); + final provide = channel.commands('access.token.provide').single; + expect(provide.payload, { + 'requestId': 'access-1', + 'token': 'mse_next', + 'expiresAt': 12000, + }); + }); + + test( + 'answers unavailable when the provider fails or returns a buyer bearer', + () async { + var calls = 0; + final (controller, channel) = await running(SeatLayerConfiguration( + event: 'ev_staff', + manageAccessTokenProvider: (_) { + calls += 1; + if (calls == 1) throw StateError('signed out'); + return const ManageAccessToken(token: 'bse_buyer'); + }, + )); + for (var i = 0; i < 2; i += 1) { + _event( + controller, + 'access.token.request', + {'requestId': 'access-$i', 'reason': 'refresh'}, + 2 + i, + ); + await pumpEventQueue(); + } + expect(channel.commands('access.token.provide'), isEmpty); + expect(channel.commands('access.token.unavailable').length, 2); + }); + + test('reports the selection and the live connection', () async { + final (controller, _) = await running( + SeatLayerConfiguration(event: 'ev_staff', manageAccessToken: _grant), + ); + final selections = >[]; + final connections = []; + controller.onSelectionChanged.listen(selections.add); + controller.onStaffConnectionChanged.listen(connections.add); + _event( + controller, + 'selection.changed', + { + 'seats': [ + { + 'id': 'row-a:1', + 'label': 'A-1', + 'objectId': 'row-a', + 'objectType': 'seat', + 'categoryKey': 'std', + }, + ], + }, + 2, + ); + _event( + controller, + 'staff.connection', + {'status': 'reconnecting', 'lastMessageAt': 77}, + 3, + ); + await pumpEventQueue(); + expect(selections.single.single.label, 'A-1'); + expect(connections.single.status, 'reconnecting'); + expect(connections.single.lastMessageAt, 77); + expect(connections.single.isLive, isFalse); + }); + + test('sends the staff commands', () async { + final (controller, channel) = await running( + SeatLayerConfiguration(event: 'ev_staff', manageAccessToken: _grant), + ); + + final found = controller.focusSection('Stalls'); + await pumpEventQueue(); + final focus = channel.commands('staff.focusSection').single; + expect(focus.payload, {'label': 'Stalls'}); + _reply(controller, focus, {'found': true}); + expect(await found, isTrue); + + final unavailable = controller.setUnavailableObjects( + ['B-1'], + reason: 'Kept for renewal', + ); + await pumpEventQueue(); + final command = channel.commands('staff.setUnavailableObjects').single; + expect(command.payload, { + 'objects': ['B-1'], + 'reason': 'Kept for renewal', + }); + _reply(controller, command); + await unavailable; + + final prices = controller.setCategoryPrices({ + 'std': const StaffCategoryPrice.amount(40), + 'vip': const StaffCategoryPrice.range(min: 60, max: 80), + }); + await pumpEventQueue(); + final priced = channel.commands('staff.setCategoryPrices').single; + expect(priced.payload, { + 'prices': { + 'std': 40, + 'vip': {'min': 60, 'max': 80}, + }, + }); + _reply(controller, priced); + await prices; + }); + }); +} From fc325d430cb05d081ab01b9c8e0ebbe25bd3722e Mon Sep 17 00:00:00 2001 From: navin10sharma <3096611+navin10sharma@users.noreply.github.com> Date: Sat, 26 Sep 2026 03:56:01 +0530 Subject: [PATCH 2/2] Docs: staff map in the README and the Unreleased changelog --- CHANGELOG.md | 14 ++++++++++++++ README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bc03f3..e1d429a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 4a4086a..8471ef8 100644 --- a/README.md +++ b/README.md @@ -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`.