@escapenavigator/hooks 2.0.48 → 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,39 +48,31 @@ 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"));
57
58
  const react_i18next_1 = require("react-i18next");
58
59
  const use_players_and_variation_picker_1 = require("../use-players-and-variation-picker");
60
+ const ticket_row_1 = require("./ticket-row");
59
61
  /**
60
- * Single shared component that renders the "Variation + Players" picker used
61
- * by the admin order modals, the widget order flow and the orders package.
62
+ * Единый пикер «вариация + состав», который используют админские модалки
63
+ * заказа, виджет и пакет orders.
62
64
  *
63
- * Rationale: the variation-picker logic lives behind
64
- * `usePlayersAndVariationPicker`, but the surrounding UI (Tag row, player
65
- * buttons, kids row, price hint, save button) was reimplemented in three
66
- * places with subtle drift. This component is the single source of truth.
65
+ * Состав описывается категориями участников тарифа: взрослые, дети до 7,
66
+ * студенты, пенсионеры — все одинаковыми строками со степпером. Цена
67
+ * категории считается от доли групповой цены на текущий состав, поэтому она
68
+ * живая: гость в команде из шести дешевле, чем в команде из двух.
67
69
  */
68
- 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, }) => {
69
- 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;
70
72
  /**
71
- * Страховка от «голых ключей» перевода. Пикер рендерит `prices:*` /
72
- * `common:*` через переданный хостом `t`, но сам хост часто берёт
73
- * `t` из `useTranslation()` без этих namespace'ов. В CRM `prices`
74
- * грузится лениво — если юзер открыл карточку игры раньше, чем
75
- * какой-либо экран задекларировал этот ns, бандл никогда не
76
- * запрашивался и вместо переводов светились ключи/фолбэки.
77
- *
78
- * Декларируем namespace'ы прямо здесь: react-i18next сам догрузит
79
- * недостающие бандлы и перерендерит компонент, когда они приедут.
80
- * `useSuspense: false` — чтобы не требовать Suspense-границу у всех
81
- * хостов; вместо этого до готовности показываем скелетон ниже.
82
- * При ошибке загрузки i18next помечает ns как «loaded (failed)»,
83
- * так что вечного скелетона не будет.
73
+ * Страховка от «голых ключей» перевода: хост часто берёт `t` из
74
+ * `useTranslation()` без нужных namespace'ов, а в CRM `prices` грузится
75
+ * лениво. Декларируем их здесь — i18next догрузит бандлы и перерендерит.
84
76
  */
85
77
  const { ready: translationsReady } = (0, react_i18next_1.useTranslation)(['prices', 'common'], {
86
78
  useSuspense: false,
@@ -93,11 +85,6 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
93
85
  t: t,
94
86
  orderId,
95
87
  });
96
- /**
97
- * Прокидываем loading-сигнал наверх через ref'нутый колбэк, чтобы
98
- * не зависеть от референциальной стабильности `onLoadingChange`
99
- * со стороны хоста.
100
- */
101
88
  const onLoadingChangeRef = (0, react_1.useRef)(onLoadingChange);
102
89
  onLoadingChangeRef.current = onLoadingChange;
103
90
  (0, react_1.useEffect)(() => {
@@ -131,135 +118,149 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
131
118
  selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.duration,
132
119
  selectedVariation,
133
120
  ]);
134
- const [_players, setPlayers] = (0, react_1.useState)(players);
135
- const [_children, setChildren] = (0, react_1.useState)(child);
136
- const [_reduced, setReduced] = (0, react_1.useState)(reducedPlayers);
137
- (0, react_1.useEffect)(() => {
138
- setPlayers(players);
139
- }, [players]);
140
- (0, react_1.useEffect)(() => {
141
- setChildren(child);
142
- }, [child]);
143
- (0, react_1.useEffect)(() => {
144
- setReduced(reducedPlayers);
145
- }, [reducedPlayers]);
146
- const flexSelected = !!(flexPlayersOption === null || flexPlayersOption === void 0 ? void 0 : flexPlayersOption.selected);
147
- const handleChangePlayers = (value) => {
148
- setPlayers(value);
149
- onChange === null || onChange === void 0 ? void 0 : onChange('players', value);
150
- // Явный выбор числа снимает «Определимся на месте».
151
- if (flexSelected)
152
- flexPlayersOption === null || flexPlayersOption === void 0 ? void 0 : flexPlayersOption.onSelect(false);
153
- };
154
- const handleChangeChildren = (value) => {
155
- setChildren(value);
156
- onChange === null || onChange === void 0 ? void 0 : onChange('children', value);
157
- };
158
- const handleSelectFlex = () => {
159
- if (flexSelected)
160
- return;
161
- // Состав не определён — детей тоже сбрасываем (их выбор скрывается).
162
- if (_children)
163
- handleChangeChildren(0);
164
- flexPlayersOption === null || flexPlayersOption === void 0 ? void 0 : flexPlayersOption.onSelect(true);
165
- };
166
- // Цена 0 за число игроков = «это кол-во недоступно для выбора». Такие
167
- // ключи не рендерим кнопками и не учитываем в availablePlayerKeys/clamp.
168
- const playersOptions = (0, react_1.useMemo)(() => Object.entries(tariff)
169
- .filter(([key, price]) => key !== 'children' && !Number.isNaN(+key) && +price > 0)
170
- .map(([key, price]) => ({
171
- key: +key,
172
- price,
173
- })), [tariff]);
174
- const playersArray = (0, get_prices_1.getPricesPlayersArray)(tariff);
175
- const maxFromTariff = playersArray.length ? +playersArray[playersArray.length - 1] : 0;
176
- const minFromTariff = playersArray.length ? +playersArray[0] : 0;
177
- // Верхняя граница = пересечение тарифа и questroom-капа (с учётом
178
- // numSeatsAvailable). За пределы тарифа всё равно нельзя — нет цены.
179
- const effectiveMax = typeof maxPlayersLimit === 'number' && Number.isFinite(maxPlayersLimit)
180
- ? Math.min(maxFromTariff, maxPlayersLimit)
181
- : maxFromTariff;
182
- // Нижняя граница — приоритет questroom.playersMin (canonical), но не
183
- // ниже минимального ключа тарифа (иначе не на что кликать). Если
184
- // questroom.playersMin шире тарифа — берём тарифный минимум.
185
- const effectiveMin = typeof minPlayers === 'number' && Number.isFinite(minPlayers)
186
- ? Math.max(minFromTariff, minPlayers)
187
- : minFromTariff;
188
- // Единственный источник истины «что реально можно выбрать»: ключи
189
- // прайса тарифа, попадающие в [effectiveMin, effectiveMax] с учётом
190
- // детей. Ровно те же числа рендерятся кнопками ниже.
191
- const availablePlayerKeys = (0, react_1.useMemo)(() => playersOptions
192
- .map((p) => p.key)
193
- .filter((key) => {
194
- const total = key + (_children || 0);
195
- return total >= effectiveMin && total <= effectiveMax;
196
- }), [playersOptions, effectiveMin, effectiveMax, _children]);
197
- // Если уже выбранное (или засеянное хостом) число игроков выпало из
198
- // доступного набора — например, после смены вариации/тарифа на более
199
- // узкий диапазон — подтягиваем его к ближайшему валидному. Без этого
200
- // `values.players` остаётся «залипшим» на count, которого в новом
201
- // тарифе нет, и на бэке `tariff.price[players]` = undefined → NaN →
202
- // 500 при создании заказа. `_players === 0/undefined` трактуем как
203
- // «ещё не выбрано» и не трогаем (валидация формы заставит выбрать).
204
- // biome-ignore lint/correctness/useExhaustiveDependencies: однонаправленный clamp, зависим только от набора и значения
205
- (0, react_1.useEffect)(() => {
206
- if (!availablePlayerKeys.length)
207
- return;
208
- if (!_players)
209
- return;
210
- if (availablePlayerKeys.includes(_players))
211
- return;
212
- const nearest = availablePlayerKeys.reduce((acc, key) => Math.abs(key - _players) < Math.abs(acc - _players) ? key : acc);
213
- setPlayers(nearest);
214
- onChange === null || onChange === void 0 ? void 0 : onChange('players', nearest);
215
- // eslint-disable-next-line react-hooks/exhaustive-deps
216
- }, [availablePlayerKeys, _players]);
217
- const childrenOptions = (0, react_1.useMemo)(() => {
218
- if (!tariff.child || !maxFromTariff)
219
- return [];
220
- const maxChildren = maxFromTariff + 1;
221
- return Array.from({ length: maxChildren }, (_, i) => i);
222
- }, [tariff, maxFromTariff]);
223
- // Условия льготной ставки: снимок с брони (если хост его передал) важнее
224
- // текущего тарифа — иначе повышение цен переоценило бы открытую карточку.
121
+ // Условия игры: снимок с брони (если хост его передал) важнее текущего
122
+ // тарифа — иначе повышение цен переоценило бы открытую карточку.
225
123
  const reducedConfig = (0, react_1.useMemo)(() => {
226
- var _a, _b, _c, _d, _e, _f;
124
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
227
125
  return ({
228
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,
229
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,
230
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,
231
132
  });
232
133
  }, [
233
134
  reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.minGameAmount,
234
135
  reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedRateMode,
235
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,
236
139
  selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.minGameAmount,
237
140
  selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedRateMode,
238
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,
239
145
  ]);
240
- 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 : '';
241
- 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();
242
- const hasReducedRate = !!reducedConfig.reducedRateMode && !!reducedConfig.reducedRate;
243
- // Лимит льготных мест не приходит с бэка числом: он выводится из
244
- // минимальной стоимости игры той же формулой, которой бэк посчитает цену.
245
- const maxReduced = (0, react_1.useMemo)(() => hasReducedRate
246
- ? (0, reduced_price_1.getMaxReducedPlayers)(Object.assign({ price: tariff, players: _players }, reducedConfig))
247
- : 0, [hasReducedRate, tariff, _players, reducedConfig]);
248
- const reducedCounts = (0, react_1.useMemo)(() => Array.from({ length: maxReduced + 1 }, (_, index) => index), [maxReduced]);
249
- const handleChangeReduced = (value) => {
250
- setReduced(value);
251
- 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);
211
+ };
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);
252
229
  };
253
- // Состав уменьшился (или сменилась вариация) — вчерашние 3 льготных места
254
- // могут больше не влезать в минимум. Подтягиваем вниз, иначе бэк отдал бы
255
- // `reducedPlayersLimitExceeded` уже на сохранении.
230
+ /**
231
+ * Единственная автоправка состава: смена вариации сузила сетку, и состав
232
+ * торчит выше нового потолка — иначе на бэке `price[N]` = undefined →
233
+ * NaN → 500. Внутри сетки состав не трогаем даже на закрытом уровне.
234
+ */
256
235
  // biome-ignore lint/correctness/useExhaustiveDependencies: односторонний clamp
257
236
  (0, react_1.useEffect)(() => {
258
- if (_reduced > maxReduced)
259
- 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);
260
248
  // eslint-disable-next-line react-hooks/exhaustive-deps
261
- }, [maxReduced, _reduced]);
262
- const teamPriceBase = (0, get_players_price_1.getPlayersPrice)(Object.assign({ players: _players, children: _children, price: tariff, reducedPlayers: Math.min(_reduced, maxReduced) }, 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
+ });
263
264
  const teamPrice = teamPriceBase -
264
265
  (0, get_slot_discount_amount_1.getSlotDiscountAmount)({
265
266
  price: teamPriceBase,
@@ -267,17 +268,43 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
267
268
  discountType: slotDiscountType,
268
269
  rounding: 'floor',
269
270
  });
270
- const totalHeads = _players + (_children || 0);
271
271
  const personPrice = totalHeads > 0 ? Math.round(teamPrice / totalHeads / 100) * 100 : 0;
272
- // Описание цены за детей показываем только когда дети реально выбраны.
273
- const childDescription = _children
274
- ? (_e = selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.childPriceDescription) === null || _e === void 0 ? void 0 : _e.trim()
275
- : undefined;
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
+ };
276
303
  const showVariations = pickerOptions.length > 1;
277
304
  // Клиентское описание показываем только для вариаций — у стандартного
278
305
  // (default) тарифа описание не выводим.
279
306
  const variationDescription = selectedVariation && !selectedVariation.isDefault
280
- ? ((_f = selectedVariation.description) !== null && _f !== void 0 ? _f : null)
307
+ ? ((_a = selectedVariation.description) !== null && _a !== void 0 ? _a : null)
281
308
  : null;
282
309
  const handleVariationClick = (optionId, isFree) => {
283
310
  if (!isFree)
@@ -288,12 +315,59 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
288
315
  return;
289
316
  onVariationChange === null || onVariationChange === void 0 ? void 0 : onVariationChange(change);
290
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
+ })))));
291
368
  // Show the loader whenever a slot is provided but enriched details are
292
- // not yet available (initial render before fetch starts, in-flight fetch,
293
- // or backend returned no details). This prevents the brief flash of an
294
- // empty picker UI between "slot prop arrived" and "loading=true".
295
- // Also wait for the `prices`/`common` translation bundles so the picker
296
- // 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.
297
371
  if (!translationsReady || (slot && !details)) {
298
372
  return (react_1.default.createElement(flex_columns_1.FlexColumns, { columns: 1, gr: 16 },
299
373
  react_1.default.createElement(skeleton_1.Skeleton, { visible: true, animate: true },
@@ -317,59 +391,32 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
317
391
  !!variationDescription && (react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, variationDescription)))),
318
392
  react_1.default.createElement(flex_columns_1.FlexColumns, { columns: 1, gr: 8 },
319
393
  react_1.default.createElement(flex_1.Flex, { gap: 'sm', align: 'center', justify: 'between' },
320
- react_1.default.createElement(typography_1.Typography.Text, { view: 'title', color: 'secondary' }, tariff.child ? t('prices:adults') : t('prices:choosePlayersAmount')),
321
- !flexSelected && !!_children && !!_players && tariff[_players] != null && (react_1.default.createElement(typography_1.Typography.Text, { view: 'title', color: 'secondary' }, (0, format_amount_1.formatAmount)(tariff[_players], currency)))),
322
- react_1.default.createElement(flex_1.Flex, { gap: 'xs', wrap: true, justify: 'start' },
323
- !!flexPlayersOption && (react_1.default.createElement(tag_1.Tag, { key: 'flex-players', text: flexPlayersOption.label, onClick: handleSelectFlex, view: flexSelected ? 'primary-inverted' : 'primary' })),
324
- playersOptions
325
- .filter((p) => {
326
- const total = p.key + (_children || 0);
327
- return total >= effectiveMin && total <= effectiveMax;
328
- })
329
- .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
330
- ? 'primary-inverted'
331
- : 'primary' }))))),
332
- !flexSelected && !!childrenOptions.length && (react_1.default.createElement(flex_columns_1.FlexColumns, { columns: 1, gr: 8 },
333
- react_1.default.createElement(flex_1.Flex, { gap: 'sm', align: 'center', justify: 'between' },
334
- react_1.default.createElement(typography_1.Typography.Text, { view: 'title', color: 'secondary' }, t('prices:kids')),
335
- !!_children && tariff.child != null && (react_1.default.createElement(typography_1.Typography.Text, { view: 'title', color: 'secondary' }, (0, format_amount_1.formatAmount)(_children * tariff.child, currency)))),
336
- !hidePriceHints && !!childDescription && (react_1.default.createElement("div", { style: { whiteSpace: 'pre-line' } },
337
- react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, childDescription))),
338
- react_1.default.createElement(flex_1.Flex, { gap: 'xs', wrap: true, justify: 'start' }, childrenOptions
339
- .filter((count) => _players + count <= effectiveMax)
340
- .map((count) => {
341
- const isActive = _children === count;
342
- return (react_1.default.createElement(tag_1.Tag, { key: `child-${count}`, text: count === 0 ? t('prices:noKids') : `${count}`, onClick: () => handleChangeChildren(isActive ? 0 : count), view: isActive ? 'primary-inverted' : 'primary' }));
343
- })))),
344
- !flexSelected && hasReducedRate && !!_players && (react_1.default.createElement(flex_columns_1.FlexColumns, { columns: 1, gr: 8 },
345
- react_1.default.createElement(flex_1.Flex, { gap: 'sm', align: 'center', justify: 'between' },
346
- react_1.default.createElement(typography_1.Typography.Text, { view: 'title', color: 'secondary' }, reducedTitle ||
347
- t('prices:reducedPlayers', { defaultValue: 'Льготные места' })),
348
- !!_reduced && (react_1.default.createElement(typography_1.Typography.Text, { view: 'title', color: 'secondary' }, `− ${(0, format_amount_1.formatAmount)(Math.min(_reduced, maxReduced) *
349
- (0, reduced_price_1.getReducedSeatDiscount)(Object.assign({ price: tariff, players: _players }, reducedConfig)), currency)}`))),
350
- maxReduced > 0 ? (react_1.default.createElement(flex_1.Flex, { gap: 'xs', wrap: true, justify: 'start' }, reducedCounts.map((count) => {
351
- const isActive = Math.min(_reduced, maxReduced) === count;
352
- return (react_1.default.createElement(tag_1.Tag, { key: `reduced-${count}`, text: count === 0
353
- ? t('prices:noReducedPlayers', {
354
- defaultValue: 'Нет',
355
- })
356
- : `${count}`, onClick: () => handleChangeReduced(isActive ? 0 : count), view: isActive ? 'primary-inverted' : 'primary' }));
357
- }))) : (react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, t('prices:reducedNotAvailable', {
358
- players: _players,
359
- minAmount: (0, format_amount_1.formatAmount)(reducedConfig.minGameAmount || 0, currency),
360
- defaultValue: 'На такой состав льготных мест нет: игра не может стоить меньше {{minAmount}}.',
361
- }))),
362
- !hidePriceHints && !!reducedDescription && !!_reduced && (react_1.default.createElement("div", { style: { whiteSpace: 'pre-line' } },
363
- react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, reducedDescription))))),
364
- !hidePriceHints && !flexSelected && !!totalHeads && (react_1.default.createElement(flex_1.Flex, { justify: 'start', gap: 'xs' },
365
- react_1.default.createElement(InfoMarkS_1.default, { style: { fill: 'var(--color-text-secondary)' } }),
366
- react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, t('prices:totalPriceDescription', {
367
- teamPrice: (0, format_amount_1.formatAmount)(teamPrice, currency),
368
- personPrice: (0, format_amount_1.formatAmount)(personPrice, currency),
369
- })))),
370
- hidePriceHints &&
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' }))))),
371
401
  !flexSelected &&
372
- (!!totalHeads || !!childDescription || (!!_reduced && !!reducedDescription)) && (react_1.default.createElement("div", { style: {
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}} участников',
413
+ })
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: {
373
420
  display: 'flex',
374
421
  flexDirection: 'column',
375
422
  gap: 6,
@@ -377,22 +424,16 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
377
424
  border: '1px dashed var(--color-border-secondary)',
378
425
  borderRadius: 'calc(var(--widget-radius, 12px) * 0.75)',
379
426
  boxSizing: 'border-box',
380
- } },
381
- !!totalHeads && (react_1.default.createElement(flex_1.Flex, { justify: 'start', gap: 'xs' },
382
- react_1.default.createElement(InfoMarkS_1.default, { style: { fill: 'var(--color-text-secondary)' } }),
383
- react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, t('prices:totalPriceDescription', {
384
- teamPrice: (0, format_amount_1.formatAmount)(teamPrice, currency),
385
- personPrice: (0, format_amount_1.formatAmount)(personPrice, currency),
386
- })))),
387
- !!childDescription && (react_1.default.createElement("div", { style: { whiteSpace: 'pre-line' } },
388
- react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, childDescription))),
389
- !!_reduced && !!reducedDescription && (react_1.default.createElement("div", { style: { whiteSpace: 'pre-line' } },
390
- react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, reducedDescription))))),
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: Math.min(_reduced, maxReduced),
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;
@@ -0,0 +1,18 @@
1
+ import React from 'react';
2
+ type Props = {
3
+ title: string;
4
+ priceHint: string;
5
+ description?: string;
6
+ value: number;
7
+ min: number;
8
+ max: number;
9
+ minusLabel: string;
10
+ plusLabel: string;
11
+ onChange: (value: number) => void;
12
+ };
13
+ /**
14
+ * Строка спеццены: слева что это за места и сколько они снимают
15
+ * с группового тарифа, справа сколько таких мест в брони.
16
+ */
17
+ export declare const TicketRow: React.FC<Props>;
18
+ export {};
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.TicketRow = void 0;
7
+ const MinusM_1 = __importDefault(require("@alphakits/icons/dist/MinusM"));
8
+ const PlusM_1 = __importDefault(require("@alphakits/icons/dist/PlusM"));
9
+ const typography_1 = require("@alphakits/ui/dist/typography");
10
+ const react_1 = __importDefault(require("react"));
11
+ const index_module_css_1 = __importDefault(require("./index.module.css"));
12
+ /**
13
+ * Строка спеццены: слева что это за места и сколько они снимают
14
+ * с группового тарифа, справа сколько таких мест в брони.
15
+ */
16
+ const TicketRow = ({ title, priceHint, description, value, min, max, minusLabel, plusLabel, onChange, }) => {
17
+ const active = value > min;
18
+ return (react_1.default.createElement("div", { className: active ? `${index_module_css_1.default.ticket} ${index_module_css_1.default.ticketActive}` : index_module_css_1.default.ticket },
19
+ react_1.default.createElement("div", { className: index_module_css_1.default.body },
20
+ react_1.default.createElement(typography_1.Typography.Text, { view: 'primary-small', weight: 'medium' }, title),
21
+ react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, priceHint),
22
+ !!description && (react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, description))),
23
+ react_1.default.createElement("div", { className: index_module_css_1.default.stub },
24
+ react_1.default.createElement("button", { type: 'button', className: index_module_css_1.default.stepperButton, "aria-label": minusLabel, disabled: value <= min, onClick: () => onChange(Math.max(min, value - 1)) },
25
+ react_1.default.createElement(MinusM_1.default, null)),
26
+ react_1.default.createElement("span", { className: index_module_css_1.default.stepperValue }, value),
27
+ react_1.default.createElement("button", { type: 'button', className: index_module_css_1.default.stepperButton, "aria-label": plusLabel, disabled: value >= max, onClick: () => onChange(Math.min(max, value + 1)) },
28
+ react_1.default.createElement(PlusM_1.default, null)))));
29
+ };
30
+ exports.TicketRow = TicketRow;
@@ -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,12 +24,18 @@ 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
- * Минимальная стоимость игры и льготная ставка тарифа. Лимит льготных мест
29
- * пикер считает сам (`getMaxReducedPlayers`) — той же формулой, которой бэк
30
- * посчитает цену, поэтому «сколько мест влезает» не может разойтись между
31
- * UI и броней.
36
+ * Минимальная стоимость игры и льготная ставка тарифа. Пикер и бэк считают
37
+ * цену одной формулой: льготное место снимает ставку с группового тарифа,
38
+ * итог не ниже минимума. Число мест гость выбирает сам, до размера группы.
32
39
  */
33
40
  minGameAmount?: number;
34
41
  reducedRateMode?: TariffReducedRateModeEnum | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escapenavigator/hooks",
3
- "version": "2.0.48",
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.48",
18
- "@escapenavigator/types": "^2.0.46",
19
- "@escapenavigator/utils": "^2.0.48",
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": "af1ce55d865cc7be91336093f79c50ab2ec88f34"
35
+ "gitHead": "480174116c4a107d2db7e0a4ae92fd0b4e0c63e5"
36
36
  }