@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.
- package/dist/players-and-variation-picker/index.d.ts +50 -49
- package/dist/players-and-variation-picker/index.js +260 -219
- package/dist/players-and-variation-picker/ticket-row.d.ts +18 -0
- package/dist/players-and-variation-picker/ticket-row.js +30 -0
- package/dist/use-players-and-variation-picker/types.d.ts +11 -4
- package/package.json +5 -5
|
@@ -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).
|
|
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
|
-
* выбранной
|
|
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
|
|
62
|
-
* the selected variation's tariff
|
|
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`.
|
|
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
|
|
94
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
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
|
-
|
|
123
|
+
onValidityChange?: (bookable: boolean) => void;
|
|
117
124
|
/**
|
|
118
|
-
* Прячет встроенные ценовые
|
|
119
|
-
*
|
|
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
|
-
*
|
|
141
|
-
*
|
|
141
|
+
* Единый пикер «вариация + состав», который используют админские модалки
|
|
142
|
+
* заказа, виджет и пакет orders.
|
|
142
143
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
+
* Единый пикер «вариация + состав», который используют админские модалки
|
|
63
|
+
* заказа, виджет и пакет orders.
|
|
62
64
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
|
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
|
-
* Страховка от «голых ключей»
|
|
72
|
-
* `
|
|
73
|
-
*
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
:
|
|
248
|
-
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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 (
|
|
259
|
-
|
|
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
|
-
}, [
|
|
262
|
-
const
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
? ((
|
|
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
|
|
293
|
-
//
|
|
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' },
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
!!
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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: 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
|
-
*
|
|
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.
|
|
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.
|
|
18
|
-
"@escapenavigator/types": "^2.0.
|
|
19
|
-
"@escapenavigator/utils": "^2.0.
|
|
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": "
|
|
35
|
+
"gitHead": "480174116c4a107d2db7e0a4ae92fd0b4e0c63e5"
|
|
36
36
|
}
|