@escapenavigator/hooks 2.2.8 → 2.2.10

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.
@@ -81,11 +81,16 @@ const ChoiceIcon = ({ id }) => {
81
81
  * Радио и чекбоксы площадки — кнопками-вариантами: один тап вместо попадания
82
82
  * в кружок, и форма читается как выбор, а не как анкета.
83
83
  */
84
- const ChoiceGroup = ({ label, options, isSelected, onToggle, hint, error, }) => {
85
- const tiled = options.length >= TILES_FROM;
84
+ const ChoiceGroup = ({ presentation, label, options, isSelected, onToggle, hint, error, }) => {
85
+ const tiled = presentation === 'cards' ||
86
+ ((!presentation || presentation === 'auto') && options.length >= TILES_FROM);
86
87
  return (react_1.default.createElement(ui_1.FlexColumns, { columns: 1, gr: 8 },
87
88
  react_1.default.createElement(ui_1.Typography.Text, { view: 'primary-small', weight: 'medium', color: 'primary' }, label),
88
- react_1.default.createElement("div", { role: 'group', "aria-label": label, className: tiled ? index_module_css_1.default.choiceTiles : index_module_css_1.default.choicePills }, options.map((option) => {
89
+ react_1.default.createElement("div", { role: 'group', "aria-label": label, className: presentation === 'list'
90
+ ? index_module_css_1.default.choiceList
91
+ : tiled
92
+ ? index_module_css_1.default.choiceTiles
93
+ : index_module_css_1.default.choicePills }, options.map((option) => {
89
94
  const selected = isSelected(option.id);
90
95
  return (react_1.default.createElement("button", { key: option.id, type: 'button', "aria-pressed": selected, className: index_module_css_1.default.choice, "data-selected": selected, "data-invalid": !!error, onClick: () => onToggle(option.id) },
91
96
  tiled && react_1.default.createElement(ChoiceIcon, { id: option.id }),
@@ -260,10 +265,10 @@ const LeadField = ({ field, value, error, language, profileCountry, questrooms,
260
265
  content: (0, lib_1.optionLabel)(option, language, option.id),
261
266
  })), selected: String(value || ''), error: error, onChange: ({ selected }) => onChange(selected === null || selected === void 0 ? void 0 : selected.key) }));
262
267
  case lead_field_type_enum_1.LeadFieldTypeEnum.RADIO:
263
- return (react_1.default.createElement(ChoiceGroup, { label: label, options: choiceOptions, isSelected: (id) => value === id, onToggle: (id) => onChange(id), hint: hint, error: error }));
268
+ return (react_1.default.createElement(ChoiceGroup, { presentation: field.presentation, label: label, options: choiceOptions, isSelected: (id) => value === id, onToggle: (id) => onChange(id), hint: hint, error: error }));
264
269
  case lead_field_type_enum_1.LeadFieldTypeEnum.CHECKBOX: {
265
270
  const selected = Array.isArray(value) ? value : [];
266
- return (react_1.default.createElement(ChoiceGroup, { label: label, options: choiceOptions, isSelected: (id) => selected.includes(id), onToggle: (id) => onChange(selected.includes(id)
271
+ return (react_1.default.createElement(ChoiceGroup, { presentation: field.presentation, label: label, options: choiceOptions, isSelected: (id) => selected.includes(id), onToggle: (id) => onChange(selected.includes(id)
267
272
  ? selected.filter((item) => item !== id)
268
273
  : [...selected, id]), hint: hint, error: error }));
269
274
  }
@@ -47,8 +47,9 @@ type Props = {
47
47
  /** Документ согласия на обработку ПДн (галочка есть только в РФ). */
48
48
  pdConsentUrl?: string;
49
49
  /** Модалка управляет шагом, чтобы использовать свою кнопку «Назад». */
50
- currentStep?: 1 | 2;
51
- onStepChange?: (step: 1 | 2) => void;
50
+ currentStep?: number;
51
+ preview?: boolean;
52
+ onStepChange?: (step: number) => void;
52
53
  showBackButton?: boolean;
53
54
  };
54
55
  export declare const LeadForm: React.FC<Props>;
@@ -49,6 +49,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
49
49
  exports.LeadForm = void 0;
50
50
  const ui_1 = require("@alphakits/ui");
51
51
  const lead_field_type_enum_1 = require("@escapenavigator/types/dist/lead/enums/lead-field-type.enum");
52
+ const lead_form_layout_1 = require("@escapenavigator/types/dist/lead/lead-form-layout");
52
53
  const react_1 = __importStar(require("react"));
53
54
  const personal_data_consent_1 = require("../personal-data-consent");
54
55
  const index_module_css_1 = __importDefault(require("./index.module.css"));
@@ -77,17 +78,18 @@ const CONTACT_FIELD_ORDER = {
77
78
  [lead_field_type_enum_1.LeadFieldTypeEnum.EMAIL]: 6,
78
79
  [lead_field_type_enum_1.LeadFieldTypeEnum.CONSENT]: 7,
79
80
  };
80
- const LeadForm = ({ form, source, language, profileCountry, submit, prefill, contextNote, withHeader = true, onDone, doneLabel, onSuccess, policyUrl, pdConsentUrl, currentStep, onStepChange, showBackButton = true, }) => {
81
+ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, contextNote, withHeader = true, onDone, doneLabel, onSuccess, policyUrl, pdConsentUrl, currentStep, onStepChange, showBackButton = true, preview = false, }) => {
82
+ var _a, _b;
81
83
  const t = (0, use_lead_t_1.useLeadT)();
82
84
  const consentRequired = (0, personal_data_consent_1.requiresPersonalDataConsent)(profileCountry);
83
85
  const [consent, setConsent] = (0, react_1.useState)(false);
84
86
  const [showConsentError, setShowConsentError] = (0, react_1.useState)(false);
85
87
  const [internalStep, setInternalStep] = (0, react_1.useState)(1);
86
- const step = currentStep !== null && currentStep !== void 0 ? currentStep : internalStep;
87
- const setStep = (nextStep) => {
88
+ const requestedStep = currentStep !== null && currentStep !== void 0 ? currentStep : internalStep;
89
+ const setStep = (0, react_1.useCallback)((nextStep) => {
88
90
  setInternalStep(nextStep);
89
91
  onStepChange === null || onStepChange === void 0 ? void 0 : onStepChange(nextStep);
90
- };
92
+ }, [onStepChange]);
91
93
  const [values, setValues] = (0, react_1.useState)(() => (0, lib_1.buildInitialValues)(form, prefill));
92
94
  const [errors, setErrors] = (0, react_1.useState)({});
93
95
  const [formError, setFormError] = (0, react_1.useState)();
@@ -101,7 +103,7 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
101
103
  setErrors((prev) => (prev[fieldId] ? Object.assign(Object.assign({}, prev), { [fieldId]: '' }) : prev));
102
104
  setFormError(undefined);
103
105
  };
104
- const visibleFields = form.fields.filter((field) => (0, lib_1.isVisible)(field, values));
106
+ const visibleFields = form.fields.filter((field) => (0, lib_1.isVisible)(field, values, form.fields));
105
107
  const planningFields = visibleFields.filter((field) => PLANNING_FIELD_IDS.has(field.id) || PLANNING_FIELD_TYPES.has(field.type));
106
108
  const contactFields = visibleFields
107
109
  .filter((field) => !PLANNING_FIELD_IDS.has(field.id) && !PLANNING_FIELD_TYPES.has(field.type))
@@ -110,10 +112,36 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
110
112
  return ((_a = CONTACT_FIELD_ORDER[left.type]) !== null && _a !== void 0 ? _a : 10) - ((_b = CONTACT_FIELD_ORDER[right.type]) !== null && _b !== void 0 ? _b : 10) ||
111
113
  left.order - right.order;
112
114
  });
113
- const hasTwoSteps = planningFields.length > 0 && contactFields.length > 0;
114
- const activeFields = !hasTwoSteps ? visibleFields : step === 1 ? planningFields : contactFields;
115
+ const layout = (0, lead_form_layout_1.normalizeLeadLayout)(form.layout, form.fields);
116
+ const configuredSteps = (layout === null || layout === void 0 ? void 0 : layout.mode) === 'steps'
117
+ ? layout.steps
118
+ .map((item) => (Object.assign(Object.assign({}, item), { fields: item.fieldIds
119
+ .map((id) => visibleFields.find((field) => field.id === id))
120
+ .filter(Boolean) })))
121
+ .filter((item) => item.fields.length)
122
+ : [];
123
+ const legacyTwoSteps = !layout && planningFields.length > 0 && contactFields.length > 0;
124
+ const stepCount = layout ? Math.max(1, configuredSteps.length) : legacyTwoSteps ? 2 : 1;
125
+ const step = Math.min(Math.max(1, requestedStep), stepCount);
126
+ (0, react_1.useEffect)(() => {
127
+ if (requestedStep !== step)
128
+ setStep(step);
129
+ }, [requestedStep, step, setStep]);
130
+ const hasTwoSteps = stepCount > 1;
131
+ const activeFields = layout
132
+ ? layout.mode === 'single'
133
+ ? visibleFields
134
+ : ((_a = configuredSteps[step - 1]) === null || _a === void 0 ? void 0 : _a.fields) || []
135
+ : !hasTwoSteps
136
+ ? visibleFields
137
+ : step === 1
138
+ ? planningFields
139
+ : contactFields;
115
140
  const planningFieldIds = new Set(planningFields.map((field) => field.id));
116
- const planningSummary = planningFields
141
+ const summaryFields = layout
142
+ ? configuredSteps.slice(0, -1).flatMap((item) => item.fields)
143
+ : planningFields;
144
+ const planningSummary = summaryFields
117
145
  .map((field) => {
118
146
  var _a, _b, _c;
119
147
  const value = values[field.id];
@@ -151,7 +179,7 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
151
179
  values,
152
180
  phoneValid,
153
181
  t,
154
- fieldIds: planningFieldIds,
182
+ fieldIds: new Set(activeFields.map((field) => field.id)),
155
183
  requireContact: false,
156
184
  });
157
185
  if (Object.keys(result.errors).length) {
@@ -160,7 +188,7 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
160
188
  }
161
189
  setErrors({});
162
190
  setFormError(undefined);
163
- setStep(2);
191
+ setStep(step + 1);
164
192
  };
165
193
  const handleSubmit = () => __awaiter(void 0, void 0, void 0, function* () {
166
194
  var _a;
@@ -172,11 +200,23 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
172
200
  if (Object.keys(result.errors).length || result.formError || consentMissing) {
173
201
  setErrors(result.errors);
174
202
  setFormError(result.formError);
175
- if (Object.keys(result.errors).some((fieldId) => planningFieldIds.has(fieldId))) {
203
+ const invalidStep = configuredSteps.findIndex((item) => item.fields.some((field) => result.errors[field.id]));
204
+ if (layout && invalidStep >= 0)
205
+ setStep(invalidStep + 1);
206
+ if (!layout &&
207
+ Object.keys(result.errors).some((fieldId) => planningFieldIds.has(fieldId))) {
176
208
  setStep(1);
177
209
  }
178
210
  return;
179
211
  }
212
+ if (preview) {
213
+ setSuccess({
214
+ success: true,
215
+ successTitle: form.successTitle,
216
+ successDescription: form.successDescription,
217
+ });
218
+ return;
219
+ }
180
220
  setSending(true);
181
221
  setFormError(undefined);
182
222
  const response = yield submit({
@@ -207,30 +247,33 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
207
247
  !!onDone && (react_1.default.createElement("div", { className: index_module_css_1.default.successActions },
208
248
  react_1.default.createElement(ui_1.Button, { view: 'primary', size: 's', block: true, onClick: onDone }, doneLabel || t('lead.close'))))));
209
249
  }
210
- const title = (0, lib_1.localized)(form.clientTitle, language) || t('lead.title');
250
+ const title = (0, lib_1.localized)((_b = configuredSteps[step - 1]) === null || _b === void 0 ? void 0 : _b.title, language) ||
251
+ (0, lib_1.localized)(form.clientTitle, language) ||
252
+ t('lead.title');
211
253
  const description = (0, lib_1.localized)(form.description, language);
212
254
  const stepLabel = step === 1 ? t('lead.stepPlanning') : t('lead.stepContacts');
213
- const showHeaderCopy = !hasTwoSteps || step === 1;
255
+ const showHeaderCopy = !!layout || !hasTwoSteps || step === 1;
214
256
  const dateField = activeFields.find((field) => field.type === lead_field_type_enum_1.LeadFieldTypeEnum.DATE);
215
257
  const timeField = activeFields.find((field) => field.type === lead_field_type_enum_1.LeadFieldTypeEnum.TIME);
216
258
  return (react_1.default.createElement("div", { className: index_module_css_1.default.form },
217
259
  withHeader && (react_1.default.createElement("div", { className: index_module_css_1.default.header },
218
260
  hasTwoSteps ? (react_1.default.createElement(react_1.default.Fragment, null,
219
261
  react_1.default.createElement("div", { className: index_module_css_1.default.stepMeta },
220
- step === 2 && showBackButton ? (react_1.default.createElement("button", { type: 'button', className: index_module_css_1.default.backButton, onClick: () => setStep(1) },
262
+ step > 1 && showBackButton ? (react_1.default.createElement("button", { type: 'button', className: index_module_css_1.default.backButton, onClick: () => setStep(step - 1) },
221
263
  react_1.default.createElement(ArrowLeft, null),
222
264
  t('lead.back'))) : (react_1.default.createElement("span", null)),
223
- react_1.default.createElement("span", null, t('lead.stepCounter', {
265
+ react_1.default.createElement("span", null, t(layout ? 'lead.customStepCounter' : 'lead.stepCounter', {
266
+ total: stepCount,
224
267
  current: step,
225
268
  label: stepLabel,
226
269
  }))),
227
270
  react_1.default.createElement("div", { className: index_module_css_1.default.progress, "aria-hidden": 'true' },
228
- react_1.default.createElement("span", { "data-step": step })))) : (react_1.default.createElement("span", { className: index_module_css_1.default.headerAccent, "aria-hidden": 'true' })),
271
+ react_1.default.createElement("span", { "data-step": step, style: { width: `${(step / stepCount) * 100}%` } })))) : (react_1.default.createElement("span", { className: index_module_css_1.default.headerAccent, "aria-hidden": 'true' })),
229
272
  showHeaderCopy && (react_1.default.createElement("div", { className: index_module_css_1.default.headerCopy },
230
273
  react_1.default.createElement(ui_1.Typography.Title, { tag: 'h1', view: 'small', weight: 'bold' }, title),
231
274
  !!description && (react_1.default.createElement(ui_1.Typography.Text, { view: 'primary-small', color: 'secondary' }, description)))))),
232
275
  !!formError && react_1.default.createElement(ui_1.ToastPlate, { view: 'negative' }, formError),
233
- hasTwoSteps && step === 2 && !!planningSummary.length && (react_1.default.createElement("div", { className: index_module_css_1.default.selectionSummary },
276
+ hasTwoSteps && step === stepCount && !!planningSummary.length && (react_1.default.createElement("div", { className: index_module_css_1.default.selectionSummary },
234
277
  react_1.default.createElement("div", null,
235
278
  react_1.default.createElement("span", { className: index_module_css_1.default.summaryIcon, "aria-hidden": 'true' },
236
279
  react_1.default.createElement("svg", { viewBox: '0 0 24 24', fill: 'none', "aria-hidden": 'true' },
@@ -248,7 +291,7 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
248
291
  : undefined, onChange: (value) => setValue(field.id, value), onPhoneValidChange: setPhoneValid })));
249
292
  })),
250
293
  react_1.default.createElement("input", { className: index_module_css_1.default.trap, tabIndex: -1, autoComplete: 'off', "aria-hidden": 'true', value: trap, onChange: (event) => setTrap(event.target.value) }),
251
- react_1.default.createElement("div", { className: index_module_css_1.default.footer, "data-step": hasTwoSteps ? step : 1 }, hasTwoSteps && step === 1 ? (react_1.default.createElement(ui_1.Button, { view: 'primary', size: 's', block: true, rightAddons: react_1.default.createElement(ArrowRight, null), onClick: handleNext }, t('lead.next'))) : (react_1.default.createElement(react_1.default.Fragment, null,
294
+ react_1.default.createElement("div", { className: index_module_css_1.default.footer, "data-step": hasTwoSteps ? step : 1 }, step < stepCount ? (react_1.default.createElement(ui_1.Button, { view: 'primary', size: 's', block: true, rightAddons: react_1.default.createElement(ArrowRight, null), onClick: handleNext }, t(layout ? 'lead.continue' : 'lead.next'))) : (react_1.default.createElement(react_1.default.Fragment, null,
252
295
  (consentRequired || !!policyUrl) && (react_1.default.createElement(personal_data_consent_1.PersonalDataConsent, { required: consentRequired, consentUrl: pdConsentUrl || '', policyUrl: policyUrl || '', checked: consent, showError: showConsentError, onChange: (checked) => {
253
296
  setConsent(checked);
254
297
  if (checked)
@@ -23,7 +23,7 @@ export declare const isMultiple: (type: LeadFieldTypeEnum) => boolean;
23
23
  * поле не валидируется и не попадает в ответы — иначе обязательная
24
24
  * «уточните какой квест» блокировала бы отправку у всех остальных.
25
25
  */
26
- export declare const isVisible: (field: LeadFormField, values: LeadFormValues) => boolean;
26
+ export declare const isVisible: (field: LeadFormField, values: LeadFormValues, fields?: LeadFormField[]) => boolean;
27
27
  /**
28
28
  * Предзаполнение по смысловой роли поля, а не по его id: форма настраивается
29
29
  * площадкой, и id полей у каждой свои. Роль (`mapsTo`) стабильна — по ней
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.buildAnswers = exports.validate = exports.buildInitialValues = exports.isVisible = exports.isMultiple = exports.optionLabel = exports.fieldText = exports.fieldTitle = exports.localized = void 0;
4
4
  const lead_field_mapping_enum_1 = require("@escapenavigator/types/dist/lead/enums/lead-field-mapping.enum");
5
5
  const lead_field_type_enum_1 = require("@escapenavigator/types/dist/lead/enums/lead-field-type.enum");
6
+ const lead_form_layout_1 = require("@escapenavigator/types/dist/lead/lead-form-layout");
6
7
  const languages_enum_1 = require("@escapenavigator/types/dist/shared/enum/languages.enum");
7
8
  /**
8
9
  * Текст на языке виджета. Площадка могла не перевести форму на все языки —
@@ -45,24 +46,9 @@ exports.isMultiple = isMultiple;
45
46
  * поле не валидируется и не попадает в ответы — иначе обязательная
46
47
  * «уточните какой квест» блокировала бы отправку у всех остальных.
47
48
  */
48
- const isVisible = (field, values) => {
49
+ const isVisible = (field, values, fields) => {
49
50
  var _a;
50
- const condition = field.conditional;
51
- if (!(condition === null || condition === void 0 ? void 0 : condition.fieldId))
52
- return true;
53
- const parentValue = values[condition.fieldId];
54
- const optionIds = ((_a = condition.optionIds) === null || _a === void 0 ? void 0 : _a.length)
55
- ? condition.optionIds
56
- : condition.optionId
57
- ? [condition.optionId]
58
- : [];
59
- if (!optionIds.length) {
60
- return Array.isArray(parentValue) ? parentValue.length > 0 : !!parentValue;
61
- }
62
- if (Array.isArray(parentValue)) {
63
- return parentValue.some((value) => optionIds.includes(value));
64
- }
65
- return typeof parentValue === 'string' && optionIds.includes(parentValue);
51
+ return (0, lead_form_layout_1.isLeadFieldVisible)(field, fields || [field, { id: (_a = field.conditional) === null || _a === void 0 ? void 0 : _a.fieldId }], (id) => values[id]);
66
52
  };
67
53
  exports.isVisible = isVisible;
68
54
  const buildInitialValues = (form, prefill) => {
@@ -114,7 +100,7 @@ const validate = ({ form, values, phoneValid, t, fieldIds, requireContact = true
114
100
  for (const field of form.fields) {
115
101
  if (fieldIds && !fieldIds.has(field.id))
116
102
  continue;
117
- if (!(0, exports.isVisible)(field, values))
103
+ if (!(0, exports.isVisible)(field, values, form.fields))
118
104
  continue;
119
105
  const value = values[field.id];
120
106
  const empty = isEmpty(value);
@@ -153,9 +139,9 @@ exports.validate = validate;
153
139
  * такого поля может не быть, а менеджеру этот контекст нужен в карточке.
154
140
  */
155
141
  const buildAnswers = (form, values, contextNote) => {
156
- const commentField = form.fields.find((field) => field.mapsTo === lead_field_mapping_enum_1.LeadFieldMappingEnum.COMMENT && (0, exports.isVisible)(field, values));
142
+ const commentField = form.fields.find((field) => field.mapsTo === lead_field_mapping_enum_1.LeadFieldMappingEnum.COMMENT && (0, exports.isVisible)(field, values, form.fields));
157
143
  return form.fields
158
- .filter((field) => (0, exports.isVisible)(field, values))
144
+ .filter((field) => (0, exports.isVisible)(field, values, form.fields))
159
145
  .map((field) => {
160
146
  const value = normalizeValue(values[field.id]);
161
147
  if (!contextNote || field.id !== (commentField === null || commentField === void 0 ? void 0 : commentField.id)) {
@@ -67,3 +67,21 @@ describe('lead form step validation', () => {
67
67
  expect((0, lib_1.buildInitialValues)(guestsForm)).toEqual({ guests: '' });
68
68
  });
69
69
  });
70
+ describe('conditional chains', () => {
71
+ it('excludes a required descendant when its parent is hidden despite stale answers', () => {
72
+ const fields = [
73
+ Object.assign(Object.assign({}, form.fields[0]), { id: 'audience', required: false }),
74
+ Object.assign(Object.assign({}, form.fields[0]), { id: 'interest', required: false, conditional: { fieldId: 'audience', optionId: 'children' } }),
75
+ Object.assign(Object.assign({}, form.fields[0]), { id: 'detail', conditional: { fieldId: 'interest', optionId: 'magic' } }),
76
+ ];
77
+ const values = { audience: 'adults', interest: 'magic', detail: '' };
78
+ expect((0, lib_1.isVisible)(fields[2], values, fields)).toBe(false);
79
+ expect((0, lib_1.validate)({
80
+ form: Object.assign(Object.assign({}, form), { fields }),
81
+ values,
82
+ phoneValid: false,
83
+ t: (key) => key,
84
+ requireContact: false,
85
+ }).errors).toEqual({});
86
+ });
87
+ });
@@ -10,6 +10,8 @@ const react_i18next_1 = require("react-i18next");
10
10
  * в витрине пакетов (`use-package-t`).
11
11
  */
12
12
  const DEFAULTS = {
13
+ 'lead.customStepCounter': 'Step {{current}} of {{total}}',
14
+ 'lead.continue': 'Continue',
13
15
  'lead.title': 'Leave a request',
14
16
  'lead.hint': 'Tell us what you need and we will call you back.',
15
17
  'lead.submit': 'Send',
@@ -56,6 +58,8 @@ const DEFAULTS = {
56
58
  };
57
59
  /** Новые ключи, которых ещё нет в сервисе: русский аудитории показываем сразу. */
58
60
  const RU_DEFAULTS = {
61
+ 'lead.customStepCounter': 'Шаг {{current}} из {{total}}',
62
+ 'lead.continue': 'Далее',
59
63
  'lead.footnote': 'Менеджер свяжется с вами и предложит варианты. Заявка пока не бронирует игру.',
60
64
  'lead.timeUndecided': 'Пока не определился со временем',
61
65
  'lead.guestsUnknown': 'Не знаю',
@@ -78,7 +82,7 @@ const RU_DEFAULTS = {
78
82
  };
79
83
  const useLeadT = () => {
80
84
  var _a;
81
- const { t, i18n } = (0, react_i18next_1.useTranslation)();
85
+ const { t, i18n } = (0, react_i18next_1.useTranslation)('widgets');
82
86
  const isRuLang = (_a = i18n.language) === null || _a === void 0 ? void 0 : _a.startsWith('ru');
83
87
  return (0, react_1.useCallback)((key, options) => t(key, Object.assign({ defaultValue: (isRuLang && RU_DEFAULTS[key]) || DEFAULTS[key] || key }, options)), [t, isRuLang]);
84
88
  };
@@ -135,10 +135,14 @@ export type PlayersAndVariationPickerProps = {
135
135
  hidePriceHints?: boolean;
136
136
  /**
137
137
  * Опция «Определимся на месте» (questroom.playersSelectMode=OPTIONAL).
138
- * Стоит первым пунктом и выбрана по умолчанию, но теги/строки состава
139
- * остаются видимыми — гость может указать точное число. Пока flex
140
- * выбран, ни одно число не подсвечено, ценовые подсказки скрыты:
141
- * хост рендерит «базовую стоимость от» сам.
138
+ * Выбрана по умолчанию. Пока flex выбран, ни одно число не подсвечено,
139
+ * ценовые подсказки скрыты: хост рендерит «базовую стоимость от» сам.
140
+ *
141
+ * Простой состав (только число человек) — чип стоит в одном ряду с
142
+ * числами, клик по числу снимает flex. Сложный (взрослые, дети,
143
+ * льготы) — пара чипов «Определимся на месте» / «Уже определились»:
144
+ * степперы видны только когда состав уже выбран, иначе заполненные
145
+ * строки спорят с «решим на месте».
142
146
  */
143
147
  flexPlayersOption?: {
144
148
  selected: boolean;
@@ -59,6 +59,29 @@ const react_i18next_1 = require("react-i18next");
59
59
  const use_players_and_variation_picker_1 = require("../use-players-and-variation-picker");
60
60
  const sync_composition_1 = require("./sync-composition");
61
61
  const ticket_row_1 = require("./ticket-row");
62
+ /**
63
+ * Подпись второго чипа сложного состава, пока ключа нет в каталоге переводов.
64
+ * Тон рядом с `decideOnSite` того же языка. Неизвестный язык — английский,
65
+ * как `fallbackLng` виджета.
66
+ */
67
+ const playersAlreadyDecidedFallback = {
68
+ ru: 'Уже определились',
69
+ en: 'Already decided',
70
+ sv: 'Redan bestämt',
71
+ de: 'Schon entschieden',
72
+ pl: 'Już ustaliliśmy',
73
+ es: 'Ya lo sabemos',
74
+ fr: 'Déjà décidé',
75
+ nl: 'Al besloten',
76
+ da: 'Allerede afgjort',
77
+ fi: 'Jo päätetty',
78
+ };
79
+ const playersAlreadyDecidedLabel = (translate, language) => {
80
+ const lng = (language || 'en').toLowerCase().split('-')[0];
81
+ return translate('prices:playersAlreadyDecided', {
82
+ defaultValue: playersAlreadyDecidedFallback[lng] || playersAlreadyDecidedFallback.en,
83
+ });
84
+ };
62
85
  /**
63
86
  * Единый пикер «вариация + состав», который используют админские модалки
64
87
  * заказа, виджет и пакет orders.
@@ -75,7 +98,7 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
75
98
  * `useTranslation()` без нужных namespace'ов, а в CRM `prices` грузится
76
99
  * лениво. Декларируем их здесь — i18next догрузит бандлы и перерендерит.
77
100
  */
78
- const { ready: translationsReady } = (0, react_i18next_1.useTranslation)(['prices', 'common'], {
101
+ const { ready: translationsReady, i18n } = (0, react_i18next_1.useTranslation)(['prices', 'common'], {
79
102
  useSuspense: false,
80
103
  });
81
104
  const { loading: pickerLoading, options: pickerOptions, selectedOptionId, selectedVariation, buildVariationChange, selectVariation, details, } = (0, use_players_and_variation_picker_1.usePlayersAndVariationPicker)({
@@ -242,6 +265,20 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
242
265
  }
243
266
  flexPlayersOption === null || flexPlayersOption === void 0 ? void 0 : flexPlayersOption.onSelect(true);
244
267
  };
268
+ /**
269
+ * Сложный состав: гость явно сказал, что число уже известно.
270
+ * Пока «на месте», степперы скрыты и крутить их было негде. Пустой
271
+ * состав (ещё никто не выбран) открываем минимальной группой — той же,
272
+ * по которой уже показана цена «от». Набранное раньше число не трогаем.
273
+ */
274
+ const handleSelectDecided = () => {
275
+ if (!flexSelected)
276
+ return;
277
+ if (totalHeads === 0 && effectiveMin > 0 && baseCategoryKey) {
278
+ applyComposition(Object.assign(Object.assign({}, _composition), { [baseCategoryKey]: effectiveMin }));
279
+ }
280
+ flexPlayersOption === null || flexPlayersOption === void 0 ? void 0 : flexPlayersOption.onSelect(false);
281
+ };
245
282
  /**
246
283
  * Единственная автоправка состава: смена вариации сузила сетку, и состав
247
284
  * торчит выше нового потолка — иначе на бэке `price[N]` = undefined →
@@ -419,29 +456,32 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
419
456
  ? categoryTitle(baseCategory)
420
457
  : t('prices:choosePlayersAmount')),
421
458
  !hasExtraRows && !flexSelected && !!tariff[pricingHeads] && (react_1.default.createElement(typography_1.Typography.Text, { view: 'title', color: 'secondary' }, (0, format_amount_1.formatAmount)(tariff[pricingHeads], currency)))),
422
- (!!flexPlayersOption || !hasExtraRows) && (react_1.default.createElement(flex_1.Flex, { gap: 'xs', wrap: true, justify: 'start' },
459
+ hasExtraRows && flexPlayersOption ? (react_1.default.createElement(flex_1.Flex, { gap: 'xs', wrap: true, justify: 'start' },
460
+ react_1.default.createElement(tag_1.Tag, { key: 'flex-players', text: flexPlayersOption.label, onClick: handleSelectFlex, view: flexSelected ? 'primary-inverted' : 'primary' }),
461
+ react_1.default.createElement(tag_1.Tag, { key: 'players-decided', text: playersAlreadyDecidedLabel(t, i18n.resolvedLanguage || i18n.language), onClick: handleSelectDecided, view: flexSelected ? 'primary' : 'primary-inverted' }))) : ((!!flexPlayersOption || !hasExtraRows) && (react_1.default.createElement(flex_1.Flex, { gap: 'xs', wrap: true, justify: 'start' },
423
462
  !!flexPlayersOption && (react_1.default.createElement(tag_1.Tag, { key: 'flex-players', text: flexPlayersOption.label, onClick: handleSelectFlex, view: flexSelected ? 'primary-inverted' : 'primary' })),
424
463
  !hasExtraRows &&
425
464
  baseLevelOptions.map((level) => (react_1.default.createElement(tag_1.Tag, { key: `level-${level}`, text: `${level}`, onClick: () => handleSelectLevel(level), view: !flexSelected && pricingHeads === level
426
465
  ? 'primary-inverted'
427
- : 'primary' }))))),
428
- (hasExtraRows ? categories : addOnCategories).map((category) => {
429
- var _a, _b;
430
- const value = Math.max(0, _composition[category.key] || 0);
431
- const rowMax = (_a = maxByCategory[category.key]) !== null && _a !== void 0 ? _a : value;
432
- const plusLimitHint = value >= rowMax
433
- ? capacityReached
434
- ? t('prices:capacityReached', {
435
- count: capacityMax,
436
- defaultValue: 'Максимум {{count}} участников',
437
- })
438
- : t('prices:pricingMaxReached', {
439
- count: pricingMax,
440
- defaultValue: 'Максимум {{count}} участников по этому тарифу',
441
- })
442
- : undefined;
443
- return (react_1.default.createElement(ticket_row_1.TicketRow, { key: category.key, title: categoryTitle(category), priceHint: categoryPriceHint(category), description: ((_b = category.description) === null || _b === void 0 ? void 0 : _b.trim()) || 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) }));
444
- })),
466
+ : 'primary' })))))),
467
+ (hasExtraRows ? !flexSelected : true) &&
468
+ (hasExtraRows ? categories : addOnCategories).map((category) => {
469
+ var _a, _b;
470
+ const value = Math.max(0, _composition[category.key] || 0);
471
+ const rowMax = (_a = maxByCategory[category.key]) !== null && _a !== void 0 ? _a : value;
472
+ const plusLimitHint = value >= rowMax
473
+ ? capacityReached
474
+ ? t('prices:capacityReached', {
475
+ count: capacityMax,
476
+ defaultValue: 'Максимум {{count}} участников',
477
+ })
478
+ : t('prices:pricingMaxReached', {
479
+ count: pricingMax,
480
+ defaultValue: 'Максимум {{count}} участников по этому тарифу',
481
+ })
482
+ : undefined;
483
+ return (react_1.default.createElement(ticket_row_1.TicketRow, { key: category.key, title: categoryTitle(category), priceHint: categoryPriceHint(category), description: ((_b = category.description) === null || _b === void 0 ? void 0 : _b.trim()) || 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) }));
484
+ })),
445
485
  !hidePriceHints && !flexSelected && bookable && (react_1.default.createElement(flex_columns_1.FlexColumns, { columns: 1, gr: 4, gc: 0 }, priceSummary)),
446
486
  hidePriceHints && !flexSelected && bookable && (react_1.default.createElement("div", { style: {
447
487
  display: 'flex',
@@ -1,2 +1,4 @@
1
1
  export type LocalstoreSetValue<T> = T | undefined | ((prev: T | undefined) => T | undefined);
2
+ export declare function readFromLocalstore<T>(token: string, defaultValue?: T): T | undefined;
3
+ export declare function writeToLocalstore<T>(token: string, value: T | undefined): void;
2
4
  export declare function useLocalstoreState<T>(token: string, defaultValue?: T): [T | undefined, (update: LocalstoreSetValue<T>) => void];
@@ -1,8 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readFromLocalstore = readFromLocalstore;
4
+ exports.writeToLocalstore = writeToLocalstore;
3
5
  exports.useLocalstoreState = useLocalstoreState;
4
6
  const react_1 = require("react");
5
7
  function readFromLocalstore(token, defaultValue) {
8
+ var _a;
6
9
  if (typeof window === 'undefined') {
7
10
  return defaultValue;
8
11
  }
@@ -12,11 +15,32 @@ function readFromLocalstore(token, defaultValue) {
12
15
  return JSON.parse(prev);
13
16
  }
14
17
  }
15
- catch (_a) {
16
- localStorage.removeItem(token);
18
+ catch (_b) {
19
+ try {
20
+ (_a = window.localStorage) === null || _a === void 0 ? void 0 : _a.removeItem(token);
21
+ }
22
+ catch (_c) {
23
+ // Storage itself can be blocked, not just contain invalid JSON.
24
+ }
17
25
  }
18
26
  return defaultValue;
19
27
  }
28
+ function writeToLocalstore(token, value) {
29
+ var _a, _b;
30
+ if (typeof window === 'undefined')
31
+ return;
32
+ try {
33
+ if (value) {
34
+ (_a = window.localStorage) === null || _a === void 0 ? void 0 : _a.setItem(token, JSON.stringify(value));
35
+ }
36
+ else {
37
+ (_b = window.localStorage) === null || _b === void 0 ? void 0 : _b.removeItem(token);
38
+ }
39
+ }
40
+ catch (_c) {
41
+ // Keep React state usable when storage is unavailable or its quota is full.
42
+ }
43
+ }
20
44
  function useLocalstoreState(token, defaultValue) {
21
45
  // We track defaultValue via ref so token changes don't pull in stale closures
22
46
  // while also avoiding extra effect runs when consumers pass an inline default.
@@ -41,12 +65,7 @@ function useLocalstoreState(token, defaultValue) {
41
65
  if (next === prev) {
42
66
  return prev;
43
67
  }
44
- if (next) {
45
- localStorage.setItem(token, JSON.stringify(next));
46
- }
47
- else {
48
- localStorage.removeItem(token);
49
- }
68
+ writeToLocalstore(token, next);
50
69
  return next;
51
70
  });
52
71
  }, [token]);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const index_1 = require("./index");
4
+ describe('unavailable local storage', () => {
5
+ afterEach(() => {
6
+ delete globalThis.window;
7
+ delete globalThis.localStorage;
8
+ });
9
+ it.each(['null', 'blocked'])('returns a default and allows writes with %s storage', (mode) => {
10
+ const getStorage = () => {
11
+ if (mode === 'blocked')
12
+ throw new Error('Access denied');
13
+ return null;
14
+ };
15
+ const browser = {};
16
+ Object.defineProperty(browser, 'localStorage', { get: getStorage });
17
+ globalThis.window = browser;
18
+ Object.defineProperty(globalThis, 'localStorage', { get: getStorage, configurable: true });
19
+ expect((0, index_1.readFromLocalstore)('client', 'default')).toBe('default');
20
+ expect(() => (0, index_1.writeToLocalstore)('client', undefined)).not.toThrow();
21
+ expect(() => (0, index_1.writeToLocalstore)('client', 'new value')).not.toThrow();
22
+ });
23
+ it('still removes malformed JSON when storage works', () => {
24
+ const storage = { getItem: () => '{', removeItem: jest.fn() };
25
+ globalThis.window = { localStorage: storage };
26
+ globalThis.localStorage = storage;
27
+ expect((0, index_1.readFromLocalstore)('client', 'default')).toBe('default');
28
+ expect(storage.removeItem).toHaveBeenCalledWith('client');
29
+ });
30
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escapenavigator/hooks",
3
- "version": "2.2.8",
3
+ "version": "2.2.10",
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.2.8",
18
- "@escapenavigator/types": "^2.2.7",
19
- "@escapenavigator/utils": "^2.2.7",
17
+ "@escapenavigator/services": "^2.2.9",
18
+ "@escapenavigator/types": "^2.2.8",
19
+ "@escapenavigator/utils": "^2.2.8",
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": "821f81560e48bb5554575ada7f5f8b0d7597fdcb"
35
+ "gitHead": "30230db507f7483ba425b0811f31d980d3b0a684"
36
36
  }