diff --git a/CHANGELOG.md b/CHANGELOG.md index 9496d00..a0e8e30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,21 @@ # Changelog -## Unreleased - +## 0.10.0 (2026-09-24) + +Runtime pin 0.99.0, the first runtime with `host-pricing-v1`. + +- **Host prices reach the map.** `SeatLayerPickerOptions.pricing.categories` + was declared but never sent, so a host that sells from its own catalogue + saw the chart's prices on the rail, the seat card and the cart. The picker + now boots the runtime with them (`init.config.pricing`) and + `SeatLayerPickerController.setPricing` changes them without a reload. + Needs a runtime advertising `host-pricing-v1`; an older one keeps the + chart's prices. Display only — the hold is still priced by SeatLayer. +- **Runtime pin 0.99.0** (was 0.84.1): the section overview, seat rendering + and row labels of the current web picker, the maintenance-pause message, + seats added after checkout listed in the runtime's own snapshot, host + pricing, and a seat card that no longer freezes 3D drag and pinch. The + bridge protocol range is unchanged (1–2). - **A sold-out accessibility filter can still be turned off.** A provision whose count reached zero went dark even while its switch was on, which trapped the buyer holding the last space on a filtered map. diff --git a/design/picker-spec.md b/design/picker-spec.md index 81f0490..8698388 100644 --- a/design/picker-spec.md +++ b/design/picker-spec.md @@ -2748,6 +2748,7 @@ from the wrong one. | `hold-ownership-v1`, `hold-selection-v1` | who owns a hold, and holding a named selection | | `checkout-handoff-v1`, `checkout-handoff-reject-v1` | the hold crossing to the host, and giving it back | | `cart-line-remove-v1` | removing a cart line the selection cannot name | +| `host-pricing-v1` | the host's own category and tier prices (`pricing.categories`) on the rail, the seat card, the cart and the handoff, sent in `init.config.pricing` and changed live with `picker.setPricing`; a category the host leaves out keeps the chart's price, and an older runtime keeps the chart's prices for all | A capability the runtime does not advertise is a feature that is **not offered**, never a feature that fails. diff --git a/lib/src/picker/picker_options.dart b/lib/src/picker/picker_options.dart index 2bf90eb..5a364ec 100644 --- a/lib/src/picker/picker_options.dart +++ b/lib/src/picker/picker_options.dart @@ -16,6 +16,10 @@ typedef SeatLayerCheckoutCallback = FutureOr Function( typedef SeatLayerMoneyFormatter = String Function( double amount, String currency); +/// The runtime capability that carries [SeatLayerPickerPricing.categories] +/// to the map: `init.config.pricing` and `cmd picker.setPricing`. +const String seatLayerHostPricingCapability = 'host-pricing-v1'; + /// Host-supplied prices for one category and its tiers. @immutable class SeatLayerCategoryPricing { @@ -25,14 +29,32 @@ class SeatLayerCategoryPricing { this.tiers = const {}, }); - /// Price when the category has no tiers. + /// Price when the category has no tiers, and for a tier not in [tiers]. final double? base; /// Price per tier id. final Map tiers; + + /// The runtime's shape: a bare number for a flat price, else + /// `{ base, tiers }`; null when this entry sets nothing. + Object? toBridgeJson() { + if (tiers.isEmpty) return base; + return { + if (base != null) 'base': base, + 'tiers': Map.of(tiers), + }; + } } /// Host-supplied pricing and money formatting. +/// +/// [categories] are the prices the buyer is shown — on the price rail, the +/// seat card, the cart and the checkout handoff — in place of the chart's own. +/// A ticketing host that charges from its own catalogue sets them so the map +/// says what checkout will charge; a category it leaves out keeps the chart's +/// price. Display only: the hold is still priced by SeatLayer. Needs a runtime +/// advertising [seatLayerHostPricingCapability]; an older one keeps the chart's +/// prices. @immutable class SeatLayerPickerPricing { /// Creates a pricing override. @@ -46,6 +68,16 @@ class SeatLayerPickerPricing { /// How every amount in the native chrome is rendered. final SeatLayerMoneyFormatter? formatter; + + /// The runtime's `pricing` value, or null when no category sets a price. + Map? toBridgeConfig() { + final prices = { + for (final entry in categories.entries) + if (entry.value.toBridgeJson() != null) + entry.key: entry.value.toBridgeJson(), + }; + return prices.isEmpty ? null : {'prices': prices}; + } } /// Visibility of the turnkey picker's native chrome. @@ -401,6 +433,8 @@ class SeatLayerPickerOptions { 'hideEventDetails': hideEventDetails, 'panelCollapsed': panelInitiallyCollapsed, 'languages': languages.map((locale) => locale.toLanguageTag()).toList(), + if (pricing?.toBridgeConfig() != null) + 'pricing': pricing!.toBridgeConfig(), }; } diff --git a/lib/src/picker/seat_layer_picker_controller.dart b/lib/src/picker/seat_layer_picker_controller.dart index e2fa04a..f070369 100644 --- a/lib/src/picker/seat_layer_picker_controller.dart +++ b/lib/src/picker/seat_layer_picker_controller.dart @@ -843,6 +843,26 @@ class SeatLayerPickerController extends ValueNotifier /// contain. Runtimes that do not report `floorMode` ignore it. Future showAllFloors() => setFloor(seatLayerAllFloors); + /// Whether the runtime takes host prices ([SeatLayerPickerPricing]). + bool get supportsHostPricing { + final bundle = mapController.bundleInfo; + return bundle != null && + bundle.supportsCapability(seatLayerHostPricingCapability); + } + + /// Show [pricing]'s category prices in place of the chart's without a + /// reload (null: the chart's own). Boot prices come from + /// [SeatLayerPickerOptions.pricing]; a runtime without + /// [seatLayerHostPricingCapability] keeps the chart's prices. + Future setPricing(SeatLayerPickerPricing? pricing) { + if (!supportsHostPricing) return Future.value(); + return _mutation( + 'picker.setPricing', + {'pricing': pricing?.toBridgeConfig()}, + SeatLayerPickerBusyAction.none, + ); + } + Future setColorblindSafe(bool enabled) => _mutation( 'picker.setColorblindSafe', {'on': enabled}, diff --git a/lib/src/seat_layer_configuration.dart b/lib/src/seat_layer_configuration.dart index 7d36688..5cec083 100644 --- a/lib/src/seat_layer_configuration.dart +++ b/lib/src/seat_layer_configuration.dart @@ -4,10 +4,10 @@ import 'open_enums.dart'; import 'payloads.dart'; /// This SDK's version. -const String seatLayerSdkVersion = '0.9.1'; +const String seatLayerSdkVersion = '0.10.0'; /// The SeatLayer renderer version this SDK release is pinned to. -const String seatLayerHostedWebVersion = '0.84.1'; +const String seatLayerHostedWebVersion = '0.99.0'; /// Renderer version retained only for the example app's offline fixture. /// diff --git a/pubspec.yaml b/pubspec.yaml index f21067a..90c2689 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: >- Flutter seat map SDK: a native seating chart and seat picker for iOS and Android ticketing apps, with live availability, best available, seat holds, 3D views and secure checkout. -version: 0.9.1 +version: 0.10.0 homepage: https://docs.seatlayer.io/buyer-sdk/flutter/ repository: https://github.com/seatlayer/seatlayer-flutter issue_tracker: https://github.com/seatlayer/seatlayer-flutter/issues diff --git a/test/payloads_test.dart b/test/payloads_test.dart index 16117f5..2f25461 100644 --- a/test/payloads_test.dart +++ b/test/payloads_test.dart @@ -5,7 +5,7 @@ import 'package:seatlayer/src/seat_layer_configuration.dart'; void main() { test('hosted and fixture runtime versions remain explicitly pinned', () { - expect(seatLayerHostedWebVersion, '0.84.1'); + expect(seatLayerHostedWebVersion, '0.99.0'); expect(seatLayerLegacyFixtureWebVersion, '0.68.0'); expect( seatLayerMobilePageUrl, diff --git a/test/picker_host_pricing_test.dart b/test/picker_host_pricing_test.dart new file mode 100644 index 0000000..bb804c0 --- /dev/null +++ b/test/picker_host_pricing_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:seatlayer/seatlayer.dart'; +import 'package:seatlayer/src/bridge/bridge_profile.dart'; + +void main() { + group('SeatLayerPickerPricing → host-pricing-v1', () { + test('a flat price travels as a number, a tiered one as { base, tiers }', + () { + const pricing = SeatLayerPickerPricing( + categories: { + 'gold': SeatLayerCategoryPricing(base: 486), + 'vip': SeatLayerCategoryPricing( + base: 935, + tiers: {'child': 673}, + ), + 'kids': SeatLayerCategoryPricing( + tiers: {'u12': 120}, + ), + 'unset': SeatLayerCategoryPricing(), + }, + ); + expect(pricing.toBridgeConfig(), { + 'prices': { + 'gold': 486.0, + 'vip': { + 'base': 935.0, + 'tiers': {'child': 673}, + }, + 'kids': { + 'tiers': {'u12': 120}, + }, + }, + }); + }); + + test('no category price means no pricing on the wire', () { + expect(const SeatLayerPickerPricing().toBridgeConfig(), isNull); + const formatterOnly = SeatLayerPickerOptions( + pricing: SeatLayerPickerPricing(formatter: _kroner), + ); + expect(formatterOnly.toBridgeConfig().containsKey('pricing'), isFalse); + }); + + test('the picker boots the runtime with the prices in init.config', () { + const options = SeatLayerPickerOptions( + pricing: SeatLayerPickerPricing( + categories: { + 'gold': SeatLayerCategoryPricing(base: 486), + }, + ), + ); + final payload = SeatLayerBridgeProfile.picker( + config: options.toBridgeConfig(), + ).initPayload(SeatLayerConfiguration(event: 'ev_dkk', currency: 'DKK')); + final config = payload['config']! as Map; + expect(config['currency'], 'DKK'); + expect(config['pricing'], { + 'prices': {'gold': 486.0}, + }); + }); + + test('equal prices build an equivalent profile, so nothing reboots', () { + SeatLayerBridgeProfile profile(double gold) => + SeatLayerBridgeProfile.picker( + config: SeatLayerPickerOptions( + pricing: SeatLayerPickerPricing( + categories: { + 'gold': SeatLayerCategoryPricing(base: gold), + }, + ), + ).toBridgeConfig(), + ); + expect(profile(486).equivalentTo(profile(486)), isTrue); + expect(profile(486).equivalentTo(profile(500)), isFalse); + }); + }); +} + +String _kroner(double amount, String currency) => '$currency $amount';