@escapenavigator/hooks 2.0.49 → 2.1.0

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, 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);
250
217
  };
251
- // Состав уменьшился — вчерашние 3 льготных места могут стать больше
252
- // группы. Подтягиваем к числу гостей, иначе бэк отдаст ошибку.
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,42 @@ 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
+ const hasExtraRows = (0, composition_price_1.hasExtraCategories)(categories);
293
+ /** Категории сверх групповой цены — они всегда строка со степпером. */
294
+ const addOnCategories = categories.filter((category) => !category.countsInPrice);
295
+ const handleSelectLevel = (level) => {
296
+ if (!baseCategoryKey)
297
+ return;
298
+ applyComposition(Object.assign(Object.assign({}, _composition), { [baseCategoryKey]: level }));
299
+ if (flexSelected)
300
+ flexPlayersOption === null || flexPlayersOption === void 0 ? void 0 : flexPlayersOption.onSelect(false);
301
+ };
277
302
  const showVariations = pickerOptions.length > 1;
278
303
  // Клиентское описание показываем только для вариаций — у стандартного
279
304
  // (default) тарифа описание не выводим.
280
305
  const variationDescription = selectedVariation && !selectedVariation.isDefault
281
- ? ((_e = selectedVariation.description) !== null && _e !== void 0 ? _e : null)
306
+ ? ((_a = selectedVariation.description) !== null && _a !== void 0 ? _a : null)
282
307
  : null;
283
308
  const handleVariationClick = (optionId, isFree) => {
284
309
  if (!isFree)
@@ -289,12 +314,64 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
289
314
  return;
290
315
  onVariationChange === null || onVariationChange === void 0 ? void 0 : onVariationChange(change);
291
316
  };
317
+ /**
318
+ * Название категории. Пустой заголовок — у категорий, собранных из старого
319
+ * формата: там подписи жили в переводах, а не в данных, и локаль гостя
320
+ * должна выигрывать у языка настроек венью.
321
+ */
322
+ const categoryTitle = (category) => {
323
+ if (category.title)
324
+ return category.title;
325
+ if (category.mode === tariff_category_mode_enum_1.TariffCategoryModeEnum.BASE)
326
+ return t('prices:adults');
327
+ if (!category.countsInPrice)
328
+ return t('prices:kids');
329
+ return t('prices:reducedPlayers', { defaultValue: 'Льготные места' });
330
+ };
331
+ /** Цена гостя категории на текущий состав. */
332
+ const categoryPriceHint = (category) => {
333
+ if (levelUnavailable || (!pricingHeads && category.countsInPrice)) {
334
+ return t('prices:priceDependsOnTeam', {
335
+ defaultValue: 'Цена зависит от состава',
336
+ });
337
+ }
338
+ const seatPrice = (0, composition_price_1.getCategorySeatPrice)(category, category.countsInPrice
339
+ ? perPerson
340
+ : (0, composition_price_1.getPerPersonAmount)(tariff, pricingHeads || effectiveMin));
341
+ if (!category.countsInPrice) {
342
+ return seatPrice > 0
343
+ ? t('prices:categoryOnTop', {
344
+ amount: (0, format_amount_1.formatAmount)(seatPrice, currency),
345
+ defaultValue: '+ {{amount}} за гостя, сверху к цене группы',
346
+ })
347
+ : t('prices:categoryFree', { defaultValue: 'Бесплатно' });
348
+ }
349
+ return (0, format_amount_1.formatAmount)(seatPrice, currency);
350
+ };
351
+ const priceSummary = (react_1.default.createElement(react_1.default.Fragment, null,
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' }, hasExtraRows
355
+ ? t('prices:totalPriceTeam', {
356
+ teamPrice: (0, format_amount_1.formatAmount)(teamPrice, currency),
357
+ defaultValue: 'Стоимость за команду {{teamPrice}}',
358
+ })
359
+ : t('prices:totalPriceDescription', {
360
+ teamPrice: (0, format_amount_1.formatAmount)(teamPrice, currency),
361
+ personPrice: (0, format_amount_1.formatAmount)(personPrice, currency),
362
+ }))),
363
+ minAmount > 0 && (react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, floorReached
364
+ ? t('prices:floorReached', {
365
+ minAmount: minAmountLabel,
366
+ defaultValue: 'Уже минимум {{minAmount}} — лишние льготные места цену не снижают',
367
+ })
368
+ : t('prices:floorHint', {
369
+ minAmount: minAmountLabel,
370
+ defaultValue: 'Игра не стоит меньше {{minAmount}}',
371
+ })))));
292
372
  // 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.
373
+ // not yet available. Also wait for the `prices`/`common` bundles so the
374
+ // picker never flashes raw i18n keys.
298
375
  if (!translationsReady || (slot && !details)) {
299
376
  return (react_1.default.createElement(flex_columns_1.FlexColumns, { columns: 1, gr: 16 },
300
377
  react_1.default.createElement(skeleton_1.Skeleton, { visible: true, animate: true },
@@ -318,53 +395,35 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
318
395
  !!variationDescription && (react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, variationDescription)))),
319
396
  react_1.default.createElement(flex_columns_1.FlexColumns, { columns: 1, gr: 8 },
320
397
  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;
332
- })
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: {
398
+ react_1.default.createElement(typography_1.Typography.Text, { view: 'title', color: 'secondary' }, !hasExtraRows && addOnCategories.length && baseCategory
399
+ ? categoryTitle(baseCategory)
400
+ : t('prices:choosePlayersAmount')),
401
+ !hasExtraRows && !flexSelected && !!tariff[pricingHeads] && (react_1.default.createElement(typography_1.Typography.Text, { view: 'title', color: 'secondary' }, (0, format_amount_1.formatAmount)(tariff[pricingHeads], currency)))),
402
+ !!flexPlayersOption && (react_1.default.createElement(flex_1.Flex, { gap: 'xs', wrap: true, justify: 'start' },
403
+ react_1.default.createElement(tag_1.Tag, { key: 'flex-players', text: flexPlayersOption.label, onClick: handleSelectFlex, view: flexSelected ? 'primary-inverted' : 'primary' }))),
404
+ !flexSelected && !hasExtraRows && (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' }))))),
405
+ !flexSelected &&
406
+ (hasExtraRows ? categories : addOnCategories).map((category) => {
407
+ var _a, _b;
408
+ const value = Math.max(0, _composition[category.key] || 0);
409
+ const rowMax = (_a = maxByCategory[category.key]) !== null && _a !== void 0 ? _a : value;
410
+ const plusLimitHint = value >= rowMax
411
+ ? capacityReached
412
+ ? t('prices:capacityReached', {
413
+ count: capacityMax,
414
+ defaultValue: 'Максимум {{count}} участников',
415
+ })
416
+ : t('prices:pricingMaxReached', {
417
+ count: pricingMax,
418
+ defaultValue: 'Максимум {{count}} участников по этому тарифу',
419
+ })
420
+ : undefined;
421
+ return (react_1.default.createElement(ticket_row_1.TicketRow, { key: category.key, title: categoryTitle(category), priceHint: categoryPriceHint(category), description: !hidePriceHints
422
+ ? ((_b = category.description) === null || _b === void 0 ? void 0 : _b.trim()) || undefined
423
+ : undefined, value: value, min: 0, max: rowMax, minusLabel: t('prices:minus', { defaultValue: 'На одного меньше' }), plusLabel: t('prices:plus', { defaultValue: 'На одного больше' }), plusDisabledHint: plusLimitHint, onChange: (next) => handleChangeCategory(category.key, next) }));
424
+ })),
425
+ !hidePriceHints && !flexSelected && bookable && (react_1.default.createElement(flex_columns_1.FlexColumns, { columns: 1, gr: 4, gc: 0 }, priceSummary)),
426
+ hidePriceHints && !flexSelected && bookable && (react_1.default.createElement("div", { style: {
368
427
  display: 'flex',
369
428
  flexDirection: 'column',
370
429
  gap: 6,
@@ -372,27 +431,16 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
372
431
  border: '1px dashed var(--color-border-secondary)',
373
432
  borderRadius: 'calc(var(--widget-radius, 12px) * 0.75)',
374
433
  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')))));
434
+ } }, priceSummary)),
435
+ !flexSelected && levelUnavailable && (react_1.default.createElement(toast_plate_1.ToastPlate, { view: 'attention' }, t('prices:levelUnavailable', {
436
+ count: pricingHeads,
437
+ defaultValue: 'На {{count}} участников цена не задана — выберите другое количество',
438
+ }))),
439
+ !flexSelected && belowMin && (react_1.default.createElement(toast_plate_1.ToastPlate, { view: 'attention' }, t('prices:belowMinPlayers', {
440
+ count: effectiveMin,
441
+ defaultValue: 'Минимум {{count}} участников',
442
+ }))),
443
+ !!error && (react_1.default.createElement(toast_plate_1.ToastPlate, { view: 'negative' }, error || t('prices:playersError', { minPlayers, maxPlayers: pricingMax }))),
444
+ !!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
445
  };
398
446
  exports.PlayersAndVariationPicker = PlayersAndVariationPicker;
@@ -8,6 +8,8 @@ type Props = {
8
8
  max: number;
9
9
  minusLabel: string;
10
10
  plusLabel: string;
11
+ /** Почему «плюс» больше не нажимается — показывается по клику на задисейбленную кнопку. */
12
+ plusDisabledHint?: string;
11
13
  onChange: (value: number) => void;
12
14
  };
13
15
  /**
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
@@ -6,15 +39,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
39
  exports.TicketRow = void 0;
7
40
  const MinusM_1 = __importDefault(require("@alphakits/icons/dist/MinusM"));
8
41
  const PlusM_1 = __importDefault(require("@alphakits/icons/dist/PlusM"));
42
+ const dist_1 = require("@alphakits/ui/dist");
9
43
  const typography_1 = require("@alphakits/ui/dist/typography");
10
- const react_1 = __importDefault(require("react"));
44
+ const functional_popover_panel_style_1 = require("@escapenavigator/utils/dist/functional-popover-panel-style");
45
+ const react_1 = __importStar(require("react"));
11
46
  const index_module_css_1 = __importDefault(require("./index.module.css"));
12
47
  /**
13
48
  * Строка спеццены: слева что это за места и сколько они снимают
14
49
  * с группового тарифа, справа сколько таких мест в брони.
15
50
  */
16
- const TicketRow = ({ title, priceHint, description, value, min, max, minusLabel, plusLabel, onChange, }) => {
51
+ const TicketRow = ({ title, priceHint, description, value, min, max, minusLabel, plusLabel, plusDisabledHint, onChange, }) => {
17
52
  const active = value > min;
53
+ const plusLocked = value >= max;
54
+ const plusWrapRef = (0, react_1.useRef)(null);
55
+ const [plusHintOpen, setPlusHintOpen] = (0, react_1.useState)(false);
56
+ (0, dist_1.useClickOutside)(plusWrapRef, () => setPlusHintOpen(false));
18
57
  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
58
  react_1.default.createElement("div", { className: index_module_css_1.default.body },
20
59
  react_1.default.createElement(typography_1.Typography.Text, { view: 'primary-small', weight: 'medium' }, title),
@@ -24,7 +63,19 @@ const TicketRow = ({ title, priceHint, description, value, min, max, minusLabel,
24
63
  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
64
  react_1.default.createElement(MinusM_1.default, null)),
26
65
  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)))));
66
+ react_1.default.createElement("div", { ref: plusWrapRef, className: plusLocked
67
+ ? `${index_module_css_1.default.plusWrap} ${index_module_css_1.default.plusWrapLocked}`
68
+ : index_module_css_1.default.plusWrap, onClick: () => {
69
+ if (plusLocked && plusDisabledHint)
70
+ setPlusHintOpen((open) => !open);
71
+ } },
72
+ react_1.default.createElement("button", { type: 'button', className: index_module_css_1.default.stepperButton, "aria-label": plusLabel, disabled: plusLocked, onClick: (event) => {
73
+ event.stopPropagation();
74
+ onChange(Math.min(max, value + 1));
75
+ } },
76
+ react_1.default.createElement(PlusM_1.default, null)),
77
+ !!plusDisabledHint && (react_1.default.createElement(dist_1.Popover, { open: plusHintOpen, anchorElement: plusWrapRef.current, position: 'top', offset: [0, 8], withTransition: true },
78
+ react_1.default.createElement("div", { style: (0, functional_popover_panel_style_1.functionalPopoverPanel)({ padding: 8, maxWidth: 260 }) },
79
+ react_1.default.createElement(typography_1.Typography.Text, { view: 'caps' }, plusDisabledHint))))))));
29
80
  };
30
81
  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,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.1.0",
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.1.0",
18
+ "@escapenavigator/types": "^2.1.0",
19
+ "@escapenavigator/utils": "^2.1.0",
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": "6566478707369811402ca08e2193bf0ead7a1172"
36
36
  }