@escapenavigator/hooks 2.0.49 → 2.0.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,17 @@
1
1
  import { ProfileCurrencyEnum } from '@escapenavigator/types/dist/profile/enum/profile-currency';
2
2
  import { SlotDiscountTypeEnum } from '@escapenavigator/types/dist/slot/enum/slot-discount-type.enum';
3
3
  import { TariffReducedRateModeEnum } from '@escapenavigator/types/dist/tariff/enum/tariff-reduced-rate-mode.enum';
4
+ import { OrderComposition, TariffCategory } from '@escapenavigator/types/dist/tariff/tariff-category';
4
5
  import React from 'react';
5
6
  import { usePlayersAndVariationPicker } from '../use-players-and-variation-picker';
6
7
  import { PickerSlotDetails, PickerSlotInfo, PickerSlotVariation, PickerVariationChange } from '../use-players-and-variation-picker/types';
7
8
  type LoadSlotDetailsApi = Parameters<typeof usePlayersAndVariationPicker>[0]['loadSlotDetails'];
9
+ /** Денормализованный состав для хостов, которые ещё живут на старых полях. */
10
+ export type LegacyCounts = {
11
+ players: number;
12
+ children: number;
13
+ reducedPlayers: number;
14
+ };
8
15
  export type PlayersAndVariationPickerProps = {
9
16
  /**
10
17
  * Minimal slot identity used to fetch enriched details. Pass
@@ -25,9 +32,7 @@ export type PlayersAndVariationPickerProps = {
25
32
  onVariationChange?: (change: PickerVariationChange) => void;
26
33
  /**
27
34
  * Notifies the host whenever the embedded picker resolves the currently
28
- * highlighted variation (initial load or user click). Useful when the
29
- * host needs the variation's tariff/duration outside of the picker UI
30
- * (e.g. to render a price list or seed Formik initial values).
35
+ * highlighted variation (initial load or user click).
31
36
  */
32
37
  onVariationResolved?: (variation: PickerSlotVariation | undefined) => void;
33
38
  /**
@@ -36,6 +41,20 @@ export type PlayersAndVariationPickerProps = {
36
41
  * persisted `slotId` (fix) or rely on coordinate-based booking (flex).
37
42
  */
38
43
  onDetailsLoaded?: (details: PickerSlotDetails | null | undefined) => void;
44
+ /**
45
+ * Состав по категориям. Ведущее поле: когда передано непустым, теми же
46
+ * ключами и рисуются строки. Пусто — состав собирается из `players` /
47
+ * `child` / `reducedPlayers`, чтобы не мигрировать всех хостов сразу.
48
+ */
49
+ composition?: OrderComposition;
50
+ /**
51
+ * Категории тарифа. По умолчанию берутся из выбранной вариации
52
+ * (`/slots/details`). Явно передают там, где условия нужно взять со
53
+ * снимка на брони, а не с текущего тарифа, — карточка оформленной игры.
54
+ */
55
+ categories?: TariffCategory[];
56
+ /** Fires on every composition change, вместе с денормом старых полей. */
57
+ onCompositionChange?: (composition: OrderComposition, legacy: LegacyCounts) => void;
39
58
  /** Players form field value. */
40
59
  players: number;
41
60
  /** Children form field value (defaults to 0). */
@@ -44,9 +63,8 @@ export type PlayersAndVariationPickerProps = {
44
63
  reducedPlayers?: number;
45
64
  /**
46
65
  * Льготная ставка и минимальная стоимость игры. По умолчанию берутся из
47
- * выбранной вариации (`/slots/details`). Явно передают там, где условия
48
- * нужно взять со снимка на заказе, а не с текущего тарифа — карточка уже
49
- * оформленной брони.
66
+ * выбранной вариации. Явно передают там, где условия нужно взять со
67
+ * снимка на заказе, а не с текущего тарифа.
50
68
  */
51
69
  reduced?: {
52
70
  minGameAmount?: number | null;
@@ -58,9 +76,8 @@ export type PlayersAndVariationPickerProps = {
58
76
  /** Fires when the guest changes the number of reduced seats. */
59
77
  onReducedChange?: (value: number) => void;
60
78
  /**
61
- * Override for the price map used to render player buttons. Defaults to
62
- * the selected variation's tariff; pass an explicit value when the host
63
- * already maintains its own tariff state (e.g. legacy flows).
79
+ * Override for the price map used to render the tariff grid. Defaults to
80
+ * the selected variation's tariff.
64
81
  */
65
82
  tariff?: Record<string, number>;
66
83
  currency: ProfileCurrencyEnum;
@@ -68,67 +85,51 @@ export type PlayersAndVariationPickerProps = {
68
85
  minPlayers?: number;
69
86
  /**
70
87
  * Capacity cap (questroom max, ticket-system `numSeatsAvailable`, etc.).
71
- * Player/children buttons exceeding `min(maxFromTariff, maxPlayersLimit)`
72
- * are filtered out (not just disabled).
73
88
  */
74
89
  maxPlayersLimit?: number;
75
90
  /**
76
91
  * Slot discount: basis points (1% = 100) for `percent`, an amount in
77
- * minor units for `fixed`. Applied to the team price shown in the hint,
78
- * mirroring the widget behaviour.
92
+ * minor units for `fixed`.
79
93
  */
80
94
  slotDiscount?: number;
81
95
  /** Defaults to `percent` for slots created before fixed discounts. */
82
96
  slotDiscountType?: SlotDiscountTypeEnum;
83
97
  /** External validation error label (controlled by the host form). */
84
98
  error?: string;
85
- /**
86
- * Translator. Required for the embedded `usePlayersAndVariationPicker`
87
- * (it composes the default flex duration label).
88
- */
99
+ /** Translator. */
89
100
  t: (key: string, options?: Record<string, unknown>) => string;
90
- /** Fires on every player/children change. */
101
+ /** Fires on every player/children change. Денорм из состава по категориям. */
91
102
  onChange?: (field: 'players' | 'children', value: number) => void;
92
103
  /**
93
- * When provided, renders a Save button inside the picker. The default
94
- * `players`/`children` state is buffered locally until the user saves.
104
+ * When provided, renders a Save button inside the picker. The composition
105
+ * is buffered locally until the user saves.
95
106
  */
96
- onSave?: (data: {
97
- players: number;
98
- children: number;
99
- reducedPlayers: number;
107
+ onSave?: (data: LegacyCounts & {
108
+ composition: OrderComposition;
100
109
  }) => void;
101
110
  /**
102
111
  * When editing an existing order, pass the order id here so the backend
103
112
  * excludes the current booking from occupancy calculations.
104
113
  */
105
114
  orderId?: number;
115
+ /** Fires whenever the internal loading flag changes. */
116
+ onLoadingChange?: (loading: boolean) => void;
106
117
  /**
107
- * Fires whenever the internal `usePlayersAndVariationPicker` loading
108
- * flag changes. Hosts can use it to show a full-screen busy overlay
109
- * over their form (the player section + dependent UI like upsellings)
110
- * while `/slots/details` is in flight on initial mount — without
111
- * having to duplicate the hook call themselves.
112
- *
113
- * Starts `true` and flips to `false` once the first `/slots/details`
114
- * resolves (success or error).
118
+ * Состав можно оформить: гостей хватает на минимальную игру, цена на
119
+ * такой состав задана и все влезают в комнату. Хост гасит по нему свою
120
+ * кнопку «дальше» — пикер намеренно не правит состав за гостя, поэтому
121
+ * непригодное состояние он показывает, а не прячет.
115
122
  */
116
- onLoadingChange?: (loading: boolean) => void;
123
+ onValidityChange?: (bookable: boolean) => void;
117
124
  /**
118
- * Прячет встроенные ценовые подсказки: цену в заголовке секции,
119
- * правило по детям (`childPriceDescription`) и строку «стоимость за
120
- * команду / за человека». Нужен консьюмерам, которые показывают эти
121
- * данные консолидированно в своём месте (например виджет — в пунктирной
122
- * сводке рядом с «Итого»). По умолчанию `false` — поведение не меняется.
125
+ * Прячет встроенные ценовые подсказки и показывает вместо них одну
126
+ * пунктирную сводку. Нужен виджету, который сводит цену в одно место.
123
127
  */
124
128
  hidePriceHints?: boolean;
125
129
  /**
126
130
  * Опция «Определимся на месте» (questroom.playersSelectMode=OPTIONAL).
127
- * Когда передана, первым тегом в ряду игроков рендерится отложенный
128
- * выбор состава. Пока он выбран: числовые теги не подсвечены, секция
129
- * детей скрыта (и дети сброшены в 0), ценовые подсказки пикера не
130
- * показываются — хост рендерит «базовую стоимость от» сам. Клик по
131
- * числу автоматически снимает опцию (onSelect(false)).
131
+ * Пока выбрана — строки состава скрыты и состав сброшен, ценовые
132
+ * подсказки не показываются: хост рендерит «базовую стоимость от» сам.
132
133
  */
133
134
  flexPlayersOption?: {
134
135
  selected: boolean;
@@ -137,13 +138,13 @@ export type PlayersAndVariationPickerProps = {
137
138
  };
138
139
  };
139
140
  /**
140
- * Single shared component that renders the "Variation + Players" picker used
141
- * by the admin order modals, the widget order flow and the orders package.
141
+ * Единый пикер «вариация + состав», который используют админские модалки
142
+ * заказа, виджет и пакет orders.
142
143
  *
143
- * Rationale: the variation-picker logic lives behind
144
- * `usePlayersAndVariationPicker`, but the surrounding UI (Tag row, player
145
- * buttons, kids row, price hint, save button) was reimplemented in three
146
- * places with subtle drift. This component is the single source of truth.
144
+ * Состав описывается категориями участников тарифа: взрослые, дети до 7,
145
+ * студенты, пенсионеры — все одинаковыми строками со степпером. Цена
146
+ * категории считается от доли групповой цены на текущий состав, поэтому она
147
+ * живая: гость в команде из шести дешевле, чем в команде из двух.
147
148
  */
148
149
  export declare const PlayersAndVariationPicker: React.FC<PlayersAndVariationPickerProps>;
149
150
  export {};
@@ -48,9 +48,10 @@ const skeleton_1 = require("@alphakits/ui/dist/skeleton");
48
48
  const tag_1 = require("@alphakits/ui/dist/tag");
49
49
  const toast_plate_1 = require("@alphakits/ui/dist/toast-plate");
50
50
  const typography_1 = require("@alphakits/ui/dist/typography");
51
+ const tariff_category_mode_enum_1 = require("@escapenavigator/types/dist/tariff/enum/tariff-category-mode.enum");
52
+ const composition_limits_1 = require("@escapenavigator/utils/dist/composition-limits");
53
+ const composition_price_1 = require("@escapenavigator/utils/dist/composition-price");
51
54
  const format_amount_1 = require("@escapenavigator/utils/dist/format-amount");
52
- const get_players_price_1 = require("@escapenavigator/utils/dist/get-players-price");
53
- const get_prices_1 = require("@escapenavigator/utils/dist/get-prices");
54
55
  const get_slot_discount_amount_1 = require("@escapenavigator/utils/dist/get-slot-discount-amount");
55
56
  const reduced_price_1 = require("@escapenavigator/utils/dist/reduced-price");
56
57
  const react_1 = __importStar(require("react"));
@@ -58,30 +59,20 @@ const react_i18next_1 = require("react-i18next");
58
59
  const use_players_and_variation_picker_1 = require("../use-players-and-variation-picker");
59
60
  const ticket_row_1 = require("./ticket-row");
60
61
  /**
61
- * Single shared component that renders the "Variation + Players" picker used
62
- * by the admin order modals, the widget order flow and the orders package.
62
+ * Единый пикер «вариация + состав», который используют админские модалки
63
+ * заказа, виджет и пакет orders.
63
64
  *
64
- * Rationale: the variation-picker logic lives behind
65
- * `usePlayersAndVariationPicker`, but the surrounding UI (Tag row, player
66
- * buttons, kids row, price hint, save button) was reimplemented in three
67
- * places with subtle drift. This component is the single source of truth.
65
+ * Состав описывается категориями участников тарифа: взрослые, дети до 7,
66
+ * студенты, пенсионеры — все одинаковыми строками со степпером. Цена
67
+ * категории считается от доли групповой цены на текущий состав, поэтому она
68
+ * живая: гость в команде из шести дешевле, чем в команде из двух.
68
69
  */
69
- const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loadSlotDetails, onVariationChange, onVariationResolved, onDetailsLoaded, players = 0, child = 0, reducedPlayers = 0, reduced: reducedOverride, onReducedChange, tariff: tariffOverride, currency, minPlayers, maxPlayersLimit, slotDiscount, slotDiscountType, error, t, onChange, onSave, orderId, onLoadingChange, hidePriceHints = false, flexPlayersOption, }) => {
70
- var _a, _b, _c, _d, _e, _f;
70
+ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loadSlotDetails, onVariationChange, onVariationResolved, onDetailsLoaded, composition: compositionProp, categories: categoriesOverride, onCompositionChange, players = 0, child = 0, reducedPlayers = 0, reduced: reducedOverride, onReducedChange, tariff: tariffOverride, currency, minPlayers, maxPlayersLimit, slotDiscount, slotDiscountType, error, t, onChange, onSave, orderId, onLoadingChange, onValidityChange, hidePriceHints = false, flexPlayersOption, }) => {
71
+ var _a;
71
72
  /**
72
- * Страховка от «голых ключей» перевода. Пикер рендерит `prices:*` /
73
- * `common:*` через переданный хостом `t`, но сам хост часто берёт
74
- * `t` из `useTranslation()` без этих namespace'ов. В CRM `prices`
75
- * грузится лениво — если юзер открыл карточку игры раньше, чем
76
- * какой-либо экран задекларировал этот ns, бандл никогда не
77
- * запрашивался и вместо переводов светились ключи/фолбэки.
78
- *
79
- * Декларируем namespace'ы прямо здесь: react-i18next сам догрузит
80
- * недостающие бандлы и перерендерит компонент, когда они приедут.
81
- * `useSuspense: false` — чтобы не требовать Suspense-границу у всех
82
- * хостов; вместо этого до готовности показываем скелетон ниже.
83
- * При ошибке загрузки i18next помечает ns как «loaded (failed)»,
84
- * так что вечного скелетона не будет.
73
+ * Страховка от «голых ключей» перевода: хост часто берёт `t` из
74
+ * `useTranslation()` без нужных namespace'ов, а в CRM `prices` грузится
75
+ * лениво. Декларируем их здесь — i18next догрузит бандлы и перерендерит.
85
76
  */
86
77
  const { ready: translationsReady } = (0, react_i18next_1.useTranslation)(['prices', 'common'], {
87
78
  useSuspense: false,
@@ -94,11 +85,6 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
94
85
  t: t,
95
86
  orderId,
96
87
  });
97
- /**
98
- * Прокидываем loading-сигнал наверх через ref'нутый колбэк, чтобы
99
- * не зависеть от референциальной стабильности `onLoadingChange`
100
- * со стороны хоста.
101
- */
102
88
  const onLoadingChangeRef = (0, react_1.useRef)(onLoadingChange);
103
89
  onLoadingChangeRef.current = onLoadingChange;
104
90
  (0, react_1.useEffect)(() => {
@@ -132,132 +118,149 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
132
118
  selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.duration,
133
119
  selectedVariation,
134
120
  ]);
135
- const [_players, setPlayers] = (0, react_1.useState)(players);
136
- const [_children, setChildren] = (0, react_1.useState)(child);
137
- const [_reduced, setReduced] = (0, react_1.useState)(reducedPlayers);
138
- (0, react_1.useEffect)(() => {
139
- setPlayers(players);
140
- }, [players]);
141
- (0, react_1.useEffect)(() => {
142
- setChildren(child);
143
- }, [child]);
144
- (0, react_1.useEffect)(() => {
145
- setReduced(reducedPlayers);
146
- }, [reducedPlayers]);
147
- const flexSelected = !!(flexPlayersOption === null || flexPlayersOption === void 0 ? void 0 : flexPlayersOption.selected);
148
- const handleChangePlayers = (value) => {
149
- setPlayers(value);
150
- onChange === null || onChange === void 0 ? void 0 : onChange('players', value);
151
- // Явный выбор числа снимает «Определимся на месте».
152
- if (flexSelected)
153
- flexPlayersOption === null || flexPlayersOption === void 0 ? void 0 : flexPlayersOption.onSelect(false);
154
- };
155
- const handleChangeChildren = (value) => {
156
- setChildren(value);
157
- onChange === null || onChange === void 0 ? void 0 : onChange('children', value);
158
- };
159
- const handleSelectFlex = () => {
160
- if (flexSelected)
161
- return;
162
- // Состав не определён — детей тоже сбрасываем (их выбор скрывается).
163
- if (_children)
164
- handleChangeChildren(0);
165
- flexPlayersOption === null || flexPlayersOption === void 0 ? void 0 : flexPlayersOption.onSelect(true);
166
- };
167
- // Цена 0 за число игроков = «это кол-во недоступно для выбора». Такие
168
- // ключи не рендерим кнопками и не учитываем в availablePlayerKeys/clamp.
169
- const playersOptions = (0, react_1.useMemo)(() => Object.entries(tariff)
170
- .filter(([key, price]) => key !== 'children' && !Number.isNaN(+key) && +price > 0)
171
- .map(([key, price]) => ({
172
- key: +key,
173
- price,
174
- })), [tariff]);
175
- const playersArray = (0, get_prices_1.getPricesPlayersArray)(tariff);
176
- const maxFromTariff = playersArray.length ? +playersArray[playersArray.length - 1] : 0;
177
- const minFromTariff = playersArray.length ? +playersArray[0] : 0;
178
- // Верхняя граница = пересечение тарифа и questroom-капа (с учётом
179
- // numSeatsAvailable). За пределы тарифа всё равно нельзя — нет цены.
180
- const effectiveMax = typeof maxPlayersLimit === 'number' && Number.isFinite(maxPlayersLimit)
181
- ? Math.min(maxFromTariff, maxPlayersLimit)
182
- : maxFromTariff;
183
- // Нижняя граница — приоритет questroom.playersMin (canonical), но не
184
- // ниже минимального ключа тарифа (иначе не на что кликать). Если
185
- // questroom.playersMin шире тарифа — берём тарифный минимум.
186
- const effectiveMin = typeof minPlayers === 'number' && Number.isFinite(minPlayers)
187
- ? Math.max(minFromTariff, minPlayers)
188
- : minFromTariff;
189
- // Единственный источник истины «что реально можно выбрать»: ключи
190
- // прайса тарифа, попадающие в [effectiveMin, effectiveMax] с учётом
191
- // детей. Ровно те же числа рендерятся кнопками ниже.
192
- const availablePlayerKeys = (0, react_1.useMemo)(() => playersOptions
193
- .map((p) => p.key)
194
- .filter((key) => {
195
- const total = key + (_children || 0);
196
- return total >= effectiveMin && total <= effectiveMax;
197
- }), [playersOptions, effectiveMin, effectiveMax, _children]);
198
- // Если уже выбранное (или засеянное хостом) число игроков выпало из
199
- // доступного набора — например, после смены вариации/тарифа на более
200
- // узкий диапазон — подтягиваем его к ближайшему валидному. Без этого
201
- // `values.players` остаётся «залипшим» на count, которого в новом
202
- // тарифе нет, и на бэке `tariff.price[players]` = undefined → NaN →
203
- // 500 при создании заказа. `_players === 0/undefined` трактуем как
204
- // «ещё не выбрано» и не трогаем (валидация формы заставит выбрать).
205
- // biome-ignore lint/correctness/useExhaustiveDependencies: однонаправленный clamp, зависим только от набора и значения
206
- (0, react_1.useEffect)(() => {
207
- if (!availablePlayerKeys.length)
208
- return;
209
- if (!_players)
210
- return;
211
- if (availablePlayerKeys.includes(_players))
212
- return;
213
- const nearest = availablePlayerKeys.reduce((acc, key) => Math.abs(key - _players) < Math.abs(acc - _players) ? key : acc);
214
- setPlayers(nearest);
215
- onChange === null || onChange === void 0 ? void 0 : onChange('players', nearest);
216
- // eslint-disable-next-line react-hooks/exhaustive-deps
217
- }, [availablePlayerKeys, _players]);
218
- const childrenOptions = (0, react_1.useMemo)(() => {
219
- if (!tariff.child || !maxFromTariff)
220
- return [];
221
- const maxChildren = maxFromTariff + 1;
222
- return Array.from({ length: maxChildren }, (_, i) => i);
223
- }, [tariff, maxFromTariff]);
224
- // Условия льготной ставки: снимок с брони (если хост его передал) важнее
225
- // текущего тарифа — иначе повышение цен переоценило бы открытую карточку.
121
+ // Условия игры: снимок с брони (если хост его передал) важнее текущего
122
+ // тарифа — иначе повышение цен переоценило бы открытую карточку.
226
123
  const reducedConfig = (0, react_1.useMemo)(() => {
227
- var _a, _b, _c, _d, _e, _f;
124
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
228
125
  return ({
229
126
  minGameAmount: (_b = (_a = reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.minGameAmount) !== null && _a !== void 0 ? _a : selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.minGameAmount) !== null && _b !== void 0 ? _b : 0,
230
127
  reducedRateMode: (_d = (_c = reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedRateMode) !== null && _c !== void 0 ? _c : selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedRateMode) !== null && _d !== void 0 ? _d : null,
231
128
  reducedRate: (_f = (_e = reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedRate) !== null && _e !== void 0 ? _e : selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedRate) !== null && _f !== void 0 ? _f : 0,
129
+ reducedTitle: (_h = (_g = reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedTitle) !== null && _g !== void 0 ? _g : selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedTitle) !== null && _h !== void 0 ? _h : null,
130
+ reducedPriceDescription: (_k = (_j = reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedPriceDescription) !== null && _j !== void 0 ? _j : selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedPriceDescription) !== null && _k !== void 0 ? _k : null,
131
+ childPriceDescription: (_l = selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.childPriceDescription) !== null && _l !== void 0 ? _l : null,
232
132
  });
233
133
  }, [
234
134
  reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.minGameAmount,
235
135
  reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedRateMode,
236
136
  reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedRate,
137
+ reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedTitle,
138
+ reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedPriceDescription,
237
139
  selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.minGameAmount,
238
140
  selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedRateMode,
239
141
  selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedRate,
142
+ selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedTitle,
143
+ selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedPriceDescription,
144
+ selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.childPriceDescription,
240
145
  ]);
241
- const reducedTitle = (_b = (_a = reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedTitle) !== null && _a !== void 0 ? _a : selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedTitle) !== null && _b !== void 0 ? _b : '';
242
- const reducedDescription = ((_d = (_c = reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedPriceDescription) !== null && _c !== void 0 ? _c : selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedPriceDescription) !== null && _d !== void 0 ? _d : '').trim();
243
- const hasReducedRate = !!reducedConfig.reducedRateMode && !!reducedConfig.reducedRate;
244
- // Льготных мест не больше, чем людей в составе. Пол выручки режет сумму,
245
- // а не выбор: три школьника на четверых можно отметить всегда.
246
- const maxReduced = hasReducedRate ? _players : 0;
247
- const handleChangeReduced = (value) => {
248
- setReduced(value);
249
- onReducedChange === null || onReducedChange === void 0 ? void 0 : onReducedChange(value);
146
+ /**
147
+ * Категории тарифа. Бэкенд отдаёт их заполненными, но резолвер оставлен
148
+ * ради хостов, которые ещё передают только прайс и льготную ставку.
149
+ */
150
+ const categories = (0, react_1.useMemo)(() => (0, composition_price_1.resolveCategories)({
151
+ price: tariff,
152
+ categories: categoriesOverride !== null && categoriesOverride !== void 0 ? categoriesOverride : selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.categories,
153
+ legacy: reducedConfig,
154
+ }), [tariff, categoriesOverride, selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.categories, reducedConfig]);
155
+ const [_composition, setComposition] = (0, react_1.useState)(() => compositionProp && Object.keys(compositionProp).length
156
+ ? compositionProp
157
+ : (0, composition_price_1.compositionFromLegacy)({ players, children: child, reducedPlayers }, categories));
158
+ /**
159
+ * Денорм, который пикер сам отправил наверх последним. Хост кладёт его в
160
+ * свою форму и возвращает пропсами — и без этой отметки эхо собственного
161
+ * ответа схлопнуло бы несколько скидочных категорий в одну (в старых
162
+ * полях льготные места — одно число).
163
+ */
164
+ const emittedLegacy = (0, react_1.useRef)(null);
165
+ /**
166
+ * Синхронизация сверху вниз. Состав по категориям приоритетнее старых
167
+ * полей: их читаем, только пока хост на него не перешёл.
168
+ */
169
+ (0, react_1.useEffect)(() => {
170
+ if (compositionProp && Object.keys(compositionProp).length) {
171
+ setComposition(compositionProp);
172
+ return;
173
+ }
174
+ const echo = emittedLegacy.current;
175
+ if (echo &&
176
+ echo.players === players &&
177
+ echo.children === child &&
178
+ echo.reducedPlayers === reducedPlayers) {
179
+ return;
180
+ }
181
+ setComposition((0, composition_price_1.compositionFromLegacy)({ players, children: child, reducedPlayers }, categories));
182
+ // eslint-disable-next-line react-hooks/exhaustive-deps
183
+ }, [compositionProp, players, child, reducedPlayers, categories]);
184
+ const flexSelected = !!(flexPlayersOption === null || flexPlayersOption === void 0 ? void 0 : flexPlayersOption.selected);
185
+ /**
186
+ * Границы выбора и пригодность состава. Живут в утилите, а не здесь:
187
+ * арифметика двух независимых потолков (вместимость комнаты против сетки
188
+ * `price[N]`) — ровно то место, где раньше добавленный малыш отнимал
189
+ * место у взрослых, а «плюс» на закрытом уровне отскакивал назад.
190
+ */
191
+ const limits = (0, react_1.useMemo)(() => (0, composition_limits_1.getCompositionLimits)({
192
+ price: tariff,
193
+ categories,
194
+ composition: _composition,
195
+ minPlayers,
196
+ maxPlayersLimit,
197
+ }), [tariff, categories, _composition, minPlayers, maxPlayersLimit]);
198
+ const { capacityMax, pricingMax, effectiveMin, pricingHeads, totalHeads, levelUnavailable, belowMin, capacityReached, pricingFull, maxByCategory, baseLevelOptions, } = limits;
199
+ const baseCategory = categories.find((category) => category.mode === tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE);
200
+ const baseCategoryKey = baseCategory === null || baseCategory === void 0 ? void 0 : baseCategory.key;
201
+ const applyComposition = (next) => {
202
+ setComposition(next);
203
+ const legacy = (0, composition_price_1.legacyCountsFromComposition)(next, categories);
204
+ emittedLegacy.current = legacy;
205
+ onCompositionChange === null || onCompositionChange === void 0 ? void 0 : onCompositionChange(next, legacy);
206
+ // Денорм для хостов на старых полях: их формы всё ещё хранят
207
+ // `players`/`children`/`reducedPlayers` и валидируются по ним.
208
+ onChange === null || onChange === void 0 ? void 0 : onChange('players', legacy.players);
209
+ onChange === null || onChange === void 0 ? void 0 : onChange('children', legacy.children);
210
+ onReducedChange === null || onReducedChange === void 0 ? void 0 : onReducedChange(legacy.reducedPlayers);
250
211
  };
251
- // Состав уменьшился — вчерашние 3 льготных места могут стать больше
252
- // группы. Подтягиваем к числу гостей, иначе бэк отдаст ошибку.
212
+ const handleChangeCategory = (key, value) => {
213
+ applyComposition(Object.assign(Object.assign({}, _composition), { [key]: Math.max(0, value) }));
214
+ // Явный выбор состава снимает «Определимся на месте».
215
+ if (flexSelected)
216
+ flexPlayersOption === null || flexPlayersOption === void 0 ? void 0 : flexPlayersOption.onSelect(false);
217
+ };
218
+ const handleSelectFlex = () => {
219
+ if (flexSelected)
220
+ return;
221
+ // Состав не определён: `players` остаётся минимумом квеста (по нему
222
+ // считается «базовая стоимость от»), а надбавочные категории сбрасываем
223
+ // — платить за детей, которых ещё не посчитали, гость не должен.
224
+ const addOns = categories.filter((category) => !category.countsInPrice && _composition[category.key]);
225
+ if (addOns.length) {
226
+ applyComposition(Object.assign(Object.assign({}, _composition), Object.fromEntries(addOns.map((category) => [category.key, 0]))));
227
+ }
228
+ flexPlayersOption === null || flexPlayersOption === void 0 ? void 0 : flexPlayersOption.onSelect(true);
229
+ };
230
+ /**
231
+ * Единственная автоправка состава: смена вариации сузила сетку, и состав
232
+ * торчит выше нового потолка — иначе на бэке `price[N]` = undefined →
233
+ * NaN → 500. Внутри сетки состав не трогаем даже на закрытом уровне.
234
+ */
253
235
  // biome-ignore lint/correctness/useExhaustiveDependencies: односторонний clamp
254
236
  (0, react_1.useEffect)(() => {
255
- if (_reduced > maxReduced)
256
- handleChangeReduced(maxReduced);
237
+ if (flexSelected)
238
+ return;
239
+ const clamped = (0, composition_limits_1.clampCompositionToGrid)({
240
+ price: tariff,
241
+ categories,
242
+ composition: _composition,
243
+ minPlayers,
244
+ maxPlayersLimit,
245
+ });
246
+ if (clamped)
247
+ applyComposition(clamped);
257
248
  // eslint-disable-next-line react-hooks/exhaustive-deps
258
- }, [maxReduced, _reduced]);
259
- const appliedReduced = Math.min(_reduced, maxReduced);
260
- const teamPriceBase = (0, get_players_price_1.getPlayersPrice)(Object.assign({ players: _players, children: _children, price: tariff, reducedPlayers: appliedReduced }, reducedConfig));
249
+ }, [pricingMax, pricingHeads, flexSelected]);
250
+ const perPerson = (0, composition_price_1.getPerPersonAmount)(tariff, pricingHeads);
251
+ const bookable = flexSelected || limits.bookable;
252
+ const onValidityChangeRef = (0, react_1.useRef)(onValidityChange);
253
+ onValidityChangeRef.current = onValidityChange;
254
+ (0, react_1.useEffect)(() => {
255
+ var _a;
256
+ (_a = onValidityChangeRef.current) === null || _a === void 0 ? void 0 : _a.call(onValidityChangeRef, bookable);
257
+ }, [bookable]);
258
+ const teamPriceBase = (0, composition_price_1.getCompositionPrice)({
259
+ price: tariff,
260
+ categories,
261
+ composition: _composition,
262
+ minGameAmount: reducedConfig.minGameAmount,
263
+ });
261
264
  const teamPrice = teamPriceBase -
262
265
  (0, get_slot_discount_amount_1.getSlotDiscountAmount)({
263
266
  price: teamPriceBase,
@@ -265,20 +268,43 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
265
268
  discountType: slotDiscountType,
266
269
  rounding: 'floor',
267
270
  });
268
- const totalHeads = _players + (_children || 0);
269
271
  const personPrice = totalHeads > 0 ? Math.round(teamPrice / totalHeads / 100) * 100 : 0;
270
- const seatDiscount = hasReducedRate
271
- ? (0, reduced_price_1.getReducedSeatDiscount)(Object.assign({ price: tariff, players: _players }, reducedConfig))
272
- : 0;
273
- const priceWithoutReduced = (0, get_players_price_1.getPlayersPrice)(Object.assign({ players: _players, children: _children, price: tariff, reducedPlayers: 0 }, reducedConfig));
274
- const floorReached = appliedReduced > 0 && appliedReduced * seatDiscount > priceWithoutReduced - teamPriceBase;
275
- const minAmountLabel = (0, format_amount_1.formatAmount)((0, reduced_price_1.getMinGameAmount)(reducedConfig), currency);
276
- const maxChildren = Math.max(0, effectiveMax - _players);
272
+ /**
273
+ * Пол выручки достигнут: следующая скидочная голова уже не снижает цену.
274
+ * Считаем сравнением с составом без скидок — так работает для любого
275
+ * набора категорий, а не только для одной льготной ставки.
276
+ */
277
+ const priceWithoutDiscounts = (0, composition_price_1.getCompositionPrice)({
278
+ price: tariff,
279
+ categories: categories.map((category) => category.countsInPrice ? Object.assign(Object.assign({}, category), { mode: tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE }) : category),
280
+ composition: _composition,
281
+ minGameAmount: reducedConfig.minGameAmount,
282
+ });
283
+ const minAmount = (0, reduced_price_1.getMinGameAmount)(reducedConfig);
284
+ const floorReached = minAmount > 0 && teamPriceBase <= minAmount && priceWithoutDiscounts > minAmount;
285
+ const minAmountLabel = (0, format_amount_1.formatAmount)(minAmount, currency);
286
+ /**
287
+ * Есть ли в тарифе льготные строки — категории, которые занимают место в
288
+ * цене, но платят не полную долю. Только ради них состав рисуется
289
+ * степперами: гость должен разложить группу по строкам. Без них
290
+ * раскладывать нечего, и работает прежний выбор числа тегами — он
291
+ * компактнее и привычнее.
292
+ */
293
+ const hasReducedCategories = categories.some((category) => category.countsInPrice && category.mode !== tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE);
294
+ /** Категории сверх групповой цены — они всегда строка со степпером. */
295
+ const addOnCategories = categories.filter((category) => !category.countsInPrice);
296
+ const handleSelectLevel = (level) => {
297
+ if (!baseCategoryKey)
298
+ return;
299
+ applyComposition(Object.assign(Object.assign({}, _composition), { [baseCategoryKey]: level }));
300
+ if (flexSelected)
301
+ flexPlayersOption === null || flexPlayersOption === void 0 ? void 0 : flexPlayersOption.onSelect(false);
302
+ };
277
303
  const showVariations = pickerOptions.length > 1;
278
304
  // Клиентское описание показываем только для вариаций — у стандартного
279
305
  // (default) тарифа описание не выводим.
280
306
  const variationDescription = selectedVariation && !selectedVariation.isDefault
281
- ? ((_e = selectedVariation.description) !== null && _e !== void 0 ? _e : null)
307
+ ? ((_a = selectedVariation.description) !== null && _a !== void 0 ? _a : null)
282
308
  : null;
283
309
  const handleVariationClick = (optionId, isFree) => {
284
310
  if (!isFree)
@@ -289,12 +315,59 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
289
315
  return;
290
316
  onVariationChange === null || onVariationChange === void 0 ? void 0 : onVariationChange(change);
291
317
  };
318
+ /**
319
+ * Название категории. Пустой заголовок — у категорий, собранных из старого
320
+ * формата: там подписи жили в переводах, а не в данных, и локаль гостя
321
+ * должна выигрывать у языка настроек венью.
322
+ */
323
+ const categoryTitle = (category) => {
324
+ if (category.title)
325
+ return category.title;
326
+ if (category.mode === tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE)
327
+ return t('prices:adults');
328
+ if (!category.countsInPrice)
329
+ return t('prices:kids');
330
+ return t('prices:reducedPlayers', { defaultValue: 'Льготные места' });
331
+ };
332
+ /** Цена гостя категории на текущий состав. */
333
+ const categoryPriceHint = (category) => {
334
+ if (levelUnavailable || (!pricingHeads && category.countsInPrice)) {
335
+ return t('prices:priceDependsOnTeam', {
336
+ defaultValue: 'Цена зависит от состава',
337
+ });
338
+ }
339
+ const seatPrice = (0, composition_price_1.getCategorySeatPrice)(category, category.countsInPrice
340
+ ? perPerson
341
+ : (0, composition_price_1.getPerPersonAmount)(tariff, pricingHeads || effectiveMin));
342
+ if (!category.countsInPrice) {
343
+ return seatPrice > 0
344
+ ? t('prices:categoryOnTop', {
345
+ amount: (0, format_amount_1.formatAmount)(seatPrice, currency),
346
+ defaultValue: '+ {{amount}} за гостя, сверху к цене группы',
347
+ })
348
+ : t('prices:categoryFree', { defaultValue: 'Бесплатно' });
349
+ }
350
+ return (0, format_amount_1.formatAmount)(seatPrice, currency);
351
+ };
352
+ const priceSummary = (react_1.default.createElement(react_1.default.Fragment, null,
353
+ react_1.default.createElement(flex_1.Flex, { justify: 'start', gap: 'xs' },
354
+ react_1.default.createElement(InfoMarkS_1.default, { style: { fill: 'var(--color-text-secondary)' } }),
355
+ react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, t('prices:totalPriceDescription', {
356
+ teamPrice: (0, format_amount_1.formatAmount)(teamPrice, currency),
357
+ personPrice: (0, format_amount_1.formatAmount)(personPrice, currency),
358
+ }))),
359
+ minAmount > 0 && (react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, floorReached
360
+ ? t('prices:floorReached', {
361
+ minAmount: minAmountLabel,
362
+ defaultValue: 'Уже минимум {{minAmount}} — лишние льготные места цену не снижают',
363
+ })
364
+ : t('prices:floorHint', {
365
+ minAmount: minAmountLabel,
366
+ defaultValue: 'Игра не стоит меньше {{minAmount}}',
367
+ })))));
292
368
  // Show the loader whenever a slot is provided but enriched details are
293
- // not yet available (initial render before fetch starts, in-flight fetch,
294
- // or backend returned no details). This prevents the brief flash of an
295
- // empty picker UI between "slot prop arrived" and "loading=true".
296
- // Also wait for the `prices`/`common` translation bundles so the picker
297
- // never flashes raw i18n keys.
369
+ // not yet available. Also wait for the `prices`/`common` bundles so the
370
+ // picker never flashes raw i18n keys.
298
371
  if (!translationsReady || (slot && !details)) {
299
372
  return (react_1.default.createElement(flex_columns_1.FlexColumns, { columns: 1, gr: 16 },
300
373
  react_1.default.createElement(skeleton_1.Skeleton, { visible: true, animate: true },
@@ -318,53 +391,32 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
318
391
  !!variationDescription && (react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, variationDescription)))),
319
392
  react_1.default.createElement(flex_columns_1.FlexColumns, { columns: 1, gr: 8 },
320
393
  react_1.default.createElement(flex_1.Flex, { gap: 'sm', align: 'center', justify: 'between' },
321
- react_1.default.createElement(typography_1.Typography.Text, { view: 'title', color: 'secondary' }, tariff.child ? t('prices:adults') : t('prices:choosePlayersAmount')),
322
- !flexSelected && !!_players && tariff[_players] != null && (react_1.default.createElement(typography_1.Typography.Text, { view: 'title', color: 'secondary' }, (0, format_amount_1.formatAmount)(tariff[_players], currency)))),
323
- (hasReducedRate || !!childrenOptions.length) && (react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, t('prices:groupFromTariff', {
324
- defaultValue: 'Цена группы по тарифу на этот состав',
325
- }))),
326
- react_1.default.createElement(flex_1.Flex, { gap: 'xs', wrap: true, justify: 'start' },
327
- !!flexPlayersOption && (react_1.default.createElement(tag_1.Tag, { key: 'flex-players', text: flexPlayersOption.label, onClick: handleSelectFlex, view: flexSelected ? 'primary-inverted' : 'primary' })),
328
- playersOptions
329
- .filter((p) => {
330
- const total = p.key + (_children || 0);
331
- return total >= effectiveMin && total <= effectiveMax;
394
+ react_1.default.createElement(typography_1.Typography.Text, { view: 'title', color: 'secondary' }, !hasReducedCategories && addOnCategories.length && baseCategory
395
+ ? categoryTitle(baseCategory)
396
+ : t('prices:choosePlayersAmount')),
397
+ !hasReducedCategories && !flexSelected && !!tariff[pricingHeads] && (react_1.default.createElement(typography_1.Typography.Text, { view: 'title', color: 'secondary' }, (0, format_amount_1.formatAmount)(tariff[pricingHeads], currency)))),
398
+ !!flexPlayersOption && (react_1.default.createElement(flex_1.Flex, { gap: 'xs', wrap: true, justify: 'start' },
399
+ react_1.default.createElement(tag_1.Tag, { key: 'flex-players', text: flexPlayersOption.label, onClick: handleSelectFlex, view: flexSelected ? 'primary-inverted' : 'primary' }))),
400
+ !flexSelected && !hasReducedCategories && (react_1.default.createElement(flex_1.Flex, { gap: 'xs', wrap: true, justify: 'start' }, baseLevelOptions.map((level) => (react_1.default.createElement(tag_1.Tag, { key: `level-${level}`, text: `${level}`, onClick: () => handleSelectLevel(level), view: pricingHeads === level ? 'primary-inverted' : 'primary' }))))),
401
+ !flexSelected &&
402
+ (hasReducedCategories ? categories : addOnCategories).map((category) => {
403
+ var _a, _b;
404
+ const value = Math.max(0, _composition[category.key] || 0);
405
+ return (react_1.default.createElement(ticket_row_1.TicketRow, { key: category.key, title: categoryTitle(category), priceHint: categoryPriceHint(category), description: !hidePriceHints
406
+ ? ((_a = category.description) === null || _a === void 0 ? void 0 : _a.trim()) || undefined
407
+ : undefined, value: value, min: 0, max: (_b = maxByCategory[category.key]) !== null && _b !== void 0 ? _b : value, minusLabel: t('prices:minus', { defaultValue: 'На одного меньше' }), plusLabel: t('prices:plus', { defaultValue: 'На одного больше' }), onChange: (next) => handleChangeCategory(category.key, next) }));
408
+ }),
409
+ !flexSelected && (capacityReached || pricingFull) && (react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'attention', weight: 'medium' }, capacityReached
410
+ ? t('prices:capacityReached', {
411
+ count: capacityMax,
412
+ defaultValue: 'Максимум {{count}} участников',
332
413
  })
333
- .map((p) => (react_1.default.createElement(tag_1.Tag, { key: `adult-${p.key}`, text: `${p.key}`, onClick: () => handleChangePlayers(p.key), view: !flexSelected && _players === p.key
334
- ? 'primary-inverted'
335
- : 'primary' }))))),
336
- !flexSelected && hasReducedRate && !!_players && (react_1.default.createElement(ticket_row_1.TicketRow, { title: reducedTitle ||
337
- t('prices:reducedPlayers', { defaultValue: 'Льготные места' }), priceHint: seatDiscount
338
- ? t('prices:reducedFromGroup', {
339
- amount: (0, format_amount_1.formatAmount)(seatDiscount, currency),
340
- defaultValue: '− {{amount}} от цены группы по тарифу, с человека',
341
- })
342
- : t('prices:reducedAlreadyInTariff', {
343
- defaultValue: 'Уже заложено в цену группы на этот состав',
344
- }), description: !hidePriceHints ? reducedDescription || undefined : undefined, value: appliedReduced, min: 0, max: maxReduced, minusLabel: t('prices:minus', { defaultValue: 'На одного меньше' }), plusLabel: t('prices:plus', { defaultValue: 'На одного больше' }), onChange: handleChangeReduced })),
345
- !flexSelected && !!childrenOptions.length && (react_1.default.createElement(ticket_row_1.TicketRow, { title: t('prices:kids'), priceHint: t('prices:childOnTop', {
346
- amount: (0, format_amount_1.formatAmount)(tariff.child || 0, currency),
347
- defaultValue: '+ {{amount}} за ребёнка, сверху к цене группы',
348
- }), description: !hidePriceHints
349
- ? ((_f = selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.childPriceDescription) === null || _f === void 0 ? void 0 : _f.trim()) || undefined
350
- : undefined, value: _children, min: 0, max: maxChildren, minusLabel: t('prices:minus', { defaultValue: 'На одного меньше' }), plusLabel: t('prices:plus', { defaultValue: 'На одного больше' }), onChange: handleChangeChildren })),
351
- !hidePriceHints && !flexSelected && !!totalHeads && (react_1.default.createElement(flex_columns_1.FlexColumns, { columns: 1, gr: 4, gc: 0 },
352
- react_1.default.createElement(flex_1.Flex, { justify: 'start', gap: 'xs' },
353
- react_1.default.createElement(InfoMarkS_1.default, { style: { fill: 'var(--color-text-secondary)' } }),
354
- react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, t('prices:totalPriceDescription', {
355
- teamPrice: (0, format_amount_1.formatAmount)(teamPrice, currency),
356
- personPrice: (0, format_amount_1.formatAmount)(personPrice, currency),
357
- }))),
358
- (0, reduced_price_1.getMinGameAmount)(reducedConfig) > 0 && (react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, floorReached
359
- ? t('prices:floorReached', {
360
- minAmount: minAmountLabel,
361
- defaultValue: 'Уже минимум {{minAmount}} — лишние льготные места цену не снижают',
362
- })
363
- : t('prices:floorHint', {
364
- minAmount: minAmountLabel,
365
- defaultValue: 'Игра не стоит меньше {{minAmount}}',
366
- }))))),
367
- hidePriceHints && !flexSelected && !!totalHeads && (react_1.default.createElement("div", { style: {
414
+ : t('prices:pricingMaxReached', {
415
+ count: pricingMax,
416
+ defaultValue: 'Максимум {{count}} участников по этому тарифу',
417
+ })))),
418
+ !hidePriceHints && !flexSelected && bookable && (react_1.default.createElement(flex_columns_1.FlexColumns, { columns: 1, gr: 4, gc: 0 }, priceSummary)),
419
+ hidePriceHints && !flexSelected && bookable && (react_1.default.createElement("div", { style: {
368
420
  display: 'flex',
369
421
  flexDirection: 'column',
370
422
  gap: 6,
@@ -372,27 +424,16 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
372
424
  border: '1px dashed var(--color-border-secondary)',
373
425
  borderRadius: 'calc(var(--widget-radius, 12px) * 0.75)',
374
426
  boxSizing: 'border-box',
375
- } },
376
- react_1.default.createElement(flex_1.Flex, { justify: 'start', gap: 'xs' },
377
- react_1.default.createElement(InfoMarkS_1.default, { style: { fill: 'var(--color-text-secondary)' } }),
378
- react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, t('prices:totalPriceDescription', {
379
- teamPrice: (0, format_amount_1.formatAmount)(teamPrice, currency),
380
- personPrice: (0, format_amount_1.formatAmount)(personPrice, currency),
381
- }))),
382
- (0, reduced_price_1.getMinGameAmount)(reducedConfig) > 0 && (react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, floorReached
383
- ? t('prices:floorReached', {
384
- minAmount: minAmountLabel,
385
- defaultValue: 'Уже минимум {{minAmount}} — лишние льготные места цену не снижают',
386
- })
387
- : t('prices:floorHint', {
388
- minAmount: minAmountLabel,
389
- defaultValue: 'Игра не стоит меньше {{minAmount}}',
390
- }))))),
391
- !!error && (react_1.default.createElement(toast_plate_1.ToastPlate, { view: 'negative' }, error || t('prices:playersError', { minPlayers, maxPlayers: effectiveMax }))),
392
- !!onSave && (react_1.default.createElement(button_1.Button, { onClick: () => onSave({
393
- players: _players,
394
- children: _children,
395
- reducedPlayers: appliedReduced,
396
- }), view: 'primary', size: 'xs' }, t('common:save')))));
427
+ } }, priceSummary)),
428
+ !flexSelected && levelUnavailable && (react_1.default.createElement(toast_plate_1.ToastPlate, { view: 'attention' }, t('prices:levelUnavailable', {
429
+ count: pricingHeads,
430
+ defaultValue: 'На {{count}} участников цена не задана — выберите другое количество',
431
+ }))),
432
+ !flexSelected && belowMin && (react_1.default.createElement(toast_plate_1.ToastPlate, { view: 'attention' }, t('prices:belowMinPlayers', {
433
+ count: effectiveMin,
434
+ defaultValue: 'Минимум {{count}} участников',
435
+ }))),
436
+ !!error && (react_1.default.createElement(toast_plate_1.ToastPlate, { view: 'negative' }, error || t('prices:playersError', { minPlayers, maxPlayers: pricingMax }))),
437
+ !!onSave && (react_1.default.createElement(button_1.Button, { onClick: () => onSave(Object.assign(Object.assign({}, (0, composition_price_1.legacyCountsFromComposition)(_composition, categories)), { composition: _composition })), view: 'primary', size: 'xs', disabled: !bookable }, t('common:save')))));
397
438
  };
398
439
  exports.PlayersAndVariationPicker = PlayersAndVariationPicker;
@@ -6,6 +6,7 @@
6
6
  * be plugged into either context.
7
7
  */
8
8
  import { TariffReducedRateModeEnum } from '@escapenavigator/types/dist/tariff/enum/tariff-reduced-rate-mode.enum';
9
+ import { TariffCategory } from '@escapenavigator/types/dist/tariff/tariff-category';
9
10
  export type PickerSlotVariation = {
10
11
  /**
11
12
  * Stable picker key returned by the backend (`${tariffId}-${duration}`).
@@ -23,6 +24,13 @@ export type PickerSlotVariation = {
23
24
  tariff: {
24
25
  [k: string]: number;
25
26
  };
27
+ /**
28
+ * Категории участников тарифа — по одной строке со степпером в пикере.
29
+ * Бэкенд отдаёт их заполненными всегда: у тарифов, не прошедших бэкфилл,
30
+ * их собирает `categoriesFromLegacy`, поэтому клиенту про старый формат
31
+ * знать не нужно.
32
+ */
33
+ categories?: TariffCategory[];
26
34
  childPriceDescription?: string | null;
27
35
  /**
28
36
  * Минимальная стоимость игры и льготная ставка тарифа. Пикер и бэк считают
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escapenavigator/hooks",
3
- "version": "2.0.49",
3
+ "version": "2.0.50",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -14,9 +14,9 @@
14
14
  "test": "jest"
15
15
  },
16
16
  "dependencies": {
17
- "@escapenavigator/services": "^2.0.49",
18
- "@escapenavigator/types": "^2.0.47",
19
- "@escapenavigator/utils": "^2.0.49",
17
+ "@escapenavigator/services": "^2.0.50",
18
+ "@escapenavigator/types": "^2.0.48",
19
+ "@escapenavigator/utils": "^2.0.50",
20
20
  "libphonenumber-js": "^1.12.24",
21
21
  "react": "19.2.6",
22
22
  "react-phone-input-2": "^2.15.1"
@@ -32,5 +32,5 @@
32
32
  "ts-jest": "^29.1.1",
33
33
  "typescript": "^5.6"
34
34
  },
35
- "gitHead": "9eaab88a1509930c7715945ce519eaeba879f550"
35
+ "gitHead": "480174116c4a107d2db7e0a4ae92fd0b4e0c63e5"
36
36
  }