@escapenavigator/hooks 2.1.23 → 2.1.24

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.
@@ -48,6 +48,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
48
48
  Object.defineProperty(exports, "__esModule", { value: true });
49
49
  exports.LeadForm = void 0;
50
50
  const ui_1 = require("@alphakits/ui");
51
+ const lead_field_type_enum_1 = require("@escapenavigator/types/dist/lead/enums/lead-field-type.enum");
51
52
  const react_1 = __importStar(require("react"));
52
53
  const personal_data_consent_1 = require("../personal-data-consent");
53
54
  const index_module_css_1 = __importDefault(require("./index.module.css"));
@@ -56,11 +57,23 @@ const lib_1 = require("./lib");
56
57
  const use_lead_t_1 = require("./use-lead-t");
57
58
  const ArrowRight = () => (react_1.default.createElement("svg", { width: '18', height: '18', viewBox: '0 0 24 24', fill: 'none', "aria-hidden": 'true' },
58
59
  react_1.default.createElement("path", { d: 'M5 12h14m-6-6 6 6-6 6', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' })));
60
+ const ArrowLeft = () => (react_1.default.createElement("svg", { width: '16', height: '16', viewBox: '0 0 24 24', fill: 'none', "aria-hidden": 'true' },
61
+ react_1.default.createElement("path", { d: 'M19 12H5m6 6-6-6 6-6', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' })));
62
+ const PLANNING_FIELD_TYPES = new Set([
63
+ lead_field_type_enum_1.LeadFieldTypeEnum.SELECT,
64
+ lead_field_type_enum_1.LeadFieldTypeEnum.RADIO,
65
+ lead_field_type_enum_1.LeadFieldTypeEnum.CHECKBOX,
66
+ lead_field_type_enum_1.LeadFieldTypeEnum.QUESTROOM,
67
+ lead_field_type_enum_1.LeadFieldTypeEnum.LOCATION,
68
+ lead_field_type_enum_1.LeadFieldTypeEnum.GUESTS,
69
+ lead_field_type_enum_1.LeadFieldTypeEnum.NUMBER,
70
+ ]);
59
71
  const LeadForm = ({ form, source, language, profileCountry, submit, prefill, contextNote, withHeader = true, onDone, doneLabel, onSuccess, policyUrl, pdConsentUrl, }) => {
60
72
  const t = (0, use_lead_t_1.useLeadT)();
61
73
  const consentRequired = (0, personal_data_consent_1.requiresPersonalDataConsent)(profileCountry);
62
74
  const [consent, setConsent] = (0, react_1.useState)(false);
63
75
  const [showConsentError, setShowConsentError] = (0, react_1.useState)(false);
76
+ const [step, setStep] = (0, react_1.useState)(1);
64
77
  const [values, setValues] = (0, react_1.useState)(() => (0, lib_1.buildInitialValues)(form, prefill));
65
78
  const [errors, setErrors] = (0, react_1.useState)({});
66
79
  const [formError, setFormError] = (0, react_1.useState)();
@@ -74,6 +87,61 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
74
87
  setErrors((prev) => (prev[fieldId] ? Object.assign(Object.assign({}, prev), { [fieldId]: '' }) : prev));
75
88
  setFormError(undefined);
76
89
  };
90
+ const visibleFields = form.fields.filter((field) => (0, lib_1.isVisible)(field, values));
91
+ const planningFields = visibleFields.filter((field) => PLANNING_FIELD_TYPES.has(field.type));
92
+ const contactFields = visibleFields.filter((field) => !PLANNING_FIELD_TYPES.has(field.type));
93
+ const hasTwoSteps = planningFields.length > 0 && contactFields.length > 0;
94
+ const activeFields = !hasTwoSteps ? visibleFields : step === 1 ? planningFields : contactFields;
95
+ const planningFieldIds = new Set(planningFields.map((field) => field.id));
96
+ const planningSummary = planningFields
97
+ .map((field) => {
98
+ var _a, _b, _c;
99
+ const value = values[field.id];
100
+ if (value === undefined || value === '' || value === false)
101
+ return '';
102
+ if (Array.isArray(value)) {
103
+ return value
104
+ .map((id) => {
105
+ var _a;
106
+ const selected = (_a = field.options) === null || _a === void 0 ? void 0 : _a.find((item) => item.id === id);
107
+ return selected ? (0, lib_1.optionLabel)(selected, language, id) : id;
108
+ })
109
+ .join(', ');
110
+ }
111
+ if (typeof value === 'string' && ((_a = field.options) === null || _a === void 0 ? void 0 : _a.length)) {
112
+ const selected = field.options.find((item) => item.id === value);
113
+ return selected ? (0, lib_1.optionLabel)(selected, language, value) : value;
114
+ }
115
+ if (field.type === lead_field_type_enum_1.LeadFieldTypeEnum.QUESTROOM) {
116
+ return (((_b = form.questrooms.find((item) => String(item.id) === String(value))) === null || _b === void 0 ? void 0 : _b.title) || '');
117
+ }
118
+ if (field.type === lead_field_type_enum_1.LeadFieldTypeEnum.LOCATION) {
119
+ return (((_c = form.locations.find((item) => String(item.id) === String(value))) === null || _c === void 0 ? void 0 : _c.title) || '');
120
+ }
121
+ if (field.type === lead_field_type_enum_1.LeadFieldTypeEnum.GUESTS ||
122
+ field.type === lead_field_type_enum_1.LeadFieldTypeEnum.NUMBER) {
123
+ return `${(0, lib_1.fieldTitle)(field, language)}: ${String(value)}`;
124
+ }
125
+ return String(value);
126
+ })
127
+ .filter(Boolean);
128
+ const handleNext = () => {
129
+ const result = (0, lib_1.validate)({
130
+ form,
131
+ values,
132
+ phoneValid,
133
+ t,
134
+ fieldIds: planningFieldIds,
135
+ requireContact: false,
136
+ });
137
+ if (Object.keys(result.errors).length) {
138
+ setErrors(result.errors);
139
+ return;
140
+ }
141
+ setErrors({});
142
+ setFormError(undefined);
143
+ setStep(2);
144
+ };
77
145
  const handleSubmit = () => __awaiter(void 0, void 0, void 0, function* () {
78
146
  var _a;
79
147
  if (sending)
@@ -84,6 +152,9 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
84
152
  if (Object.keys(result.errors).length || result.formError || consentMissing) {
85
153
  setErrors(result.errors);
86
154
  setFormError(result.formError);
155
+ if (Object.keys(result.errors).some((fieldId) => planningFieldIds.has(fieldId))) {
156
+ setStep(1);
157
+ }
87
158
  return;
88
159
  }
89
160
  setSending(true);
@@ -118,18 +189,34 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
118
189
  }
119
190
  const title = (0, lib_1.localized)(form.clientTitle, language) || t('lead.title');
120
191
  const description = (0, lib_1.localized)(form.description, language);
192
+ const stepLabel = step === 1 ? t('lead.stepPlanning') : t('lead.stepContacts');
193
+ const headerTitle = hasTwoSteps && step === 2 ? t('lead.stepContactsTitle') : title;
194
+ const headerDescription = hasTwoSteps && step === 2 ? t('lead.stepContactsHint') : description;
121
195
  return (react_1.default.createElement("div", { className: index_module_css_1.default.form },
122
196
  withHeader && (react_1.default.createElement("div", { className: index_module_css_1.default.header },
123
- react_1.default.createElement("span", { className: index_module_css_1.default.headerAccent, "aria-hidden": 'true' }),
124
- react_1.default.createElement(ui_1.Typography.Title, { tag: 'h1', view: 'small', weight: 'bold' }, title),
125
- !!description && (react_1.default.createElement(ui_1.Typography.Text, { view: 'primary-small', color: 'secondary' }, description)))),
197
+ hasTwoSteps ? (react_1.default.createElement(react_1.default.Fragment, null,
198
+ react_1.default.createElement("div", { className: index_module_css_1.default.stepMeta },
199
+ step === 2 ? (react_1.default.createElement("button", { type: 'button', className: index_module_css_1.default.backButton, onClick: () => setStep(1) },
200
+ react_1.default.createElement(ArrowLeft, null),
201
+ t('lead.back'))) : (react_1.default.createElement("span", null)),
202
+ react_1.default.createElement("span", null, t('lead.stepCounter', {
203
+ current: step,
204
+ label: stepLabel,
205
+ }))),
206
+ react_1.default.createElement("div", { className: index_module_css_1.default.progress, "aria-hidden": 'true' },
207
+ react_1.default.createElement("span", { "data-step": step })))) : (react_1.default.createElement("span", { className: index_module_css_1.default.headerAccent, "aria-hidden": 'true' })),
208
+ react_1.default.createElement(ui_1.Typography.Title, { tag: 'h1', view: 'small', weight: 'bold' }, headerTitle),
209
+ !!headerDescription && (react_1.default.createElement(ui_1.Typography.Text, { view: 'primary-small', color: 'secondary' }, headerDescription)))),
126
210
  !!formError && react_1.default.createElement(ui_1.ToastPlate, { view: 'negative' }, formError),
127
- react_1.default.createElement("div", { className: index_module_css_1.default.fields }, form.fields
128
- .filter((field) => (0, lib_1.isVisible)(field, values))
129
- .map((field) => (react_1.default.createElement("div", { key: field.id, className: index_module_css_1.default.fieldCard, "data-field-type": field.type },
211
+ hasTwoSteps && step === 2 && !!planningSummary.length && (react_1.default.createElement("div", { className: index_module_css_1.default.selectionSummary },
212
+ react_1.default.createElement("div", null,
213
+ react_1.default.createElement("span", null, t('lead.selection')),
214
+ react_1.default.createElement("strong", null, planningSummary.join(' · '))),
215
+ react_1.default.createElement("button", { type: 'button', onClick: () => setStep(1) }, t('lead.edit')))),
216
+ react_1.default.createElement("div", { key: hasTwoSteps ? step : 'single', className: index_module_css_1.default.fields, "data-step": hasTwoSteps ? step : 1 }, activeFields.map((field) => (react_1.default.createElement("div", { key: field.id, className: index_module_css_1.default.fieldCard, "data-field-type": field.type },
130
217
  react_1.default.createElement(lead_field_1.LeadField, { field: field, value: values[field.id], error: errors[field.id], language: language, profileCountry: profileCountry, questrooms: form.questrooms, locations: form.locations, onChange: (value) => setValue(field.id, value), onPhoneValidChange: setPhoneValid }))))),
131
218
  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) }),
132
- react_1.default.createElement("div", { className: index_module_css_1.default.footer },
219
+ react_1.default.createElement("div", { className: index_module_css_1.default.footer }, 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,
133
220
  (consentRequired || !!policyUrl) && (react_1.default.createElement(personal_data_consent_1.PersonalDataConsent, { required: consentRequired, consentUrl: pdConsentUrl || '', policyUrl: policyUrl || '', checked: consent, showError: showConsentError, onChange: (checked) => {
134
221
  setConsent(checked);
135
222
  if (checked)
@@ -138,6 +225,6 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
138
225
  react_1.default.createElement(ui_1.Button, { view: 'primary', size: 's', block: true, loading: sending, rightAddons: sending ? undefined : react_1.default.createElement(ArrowRight, null), onClick: handleSubmit }, sending
139
226
  ? t('lead.sending')
140
227
  : (0, lib_1.localized)(form.submitLabel, language) || t('lead.submit')),
141
- react_1.default.createElement(ui_1.Typography.Text, { view: 'caps', color: 'secondary', className: index_module_css_1.default.footnote, tag: 'div' }, t('lead.footnote')))));
228
+ react_1.default.createElement(ui_1.Typography.Text, { view: 'caps', color: 'secondary', className: index_module_css_1.default.footnote, tag: 'div' }, t('lead.footnote')))))));
142
229
  };
143
230
  exports.LeadForm = LeadForm;
@@ -37,13 +37,17 @@ type ValidateContext = {
37
37
  /** `InputTel` сам знает, валиден ли номер для выбранной страны. */
38
38
  phoneValid: boolean;
39
39
  t: (key: string) => string;
40
+ /** Для перехода между шагами проверяем только поля текущего шага. */
41
+ fieldIds?: Set<string>;
42
+ /** На первом шаге контакт ещё не спрашивали — общая проверка не нужна. */
43
+ requireContact?: boolean;
40
44
  };
41
45
  /**
42
46
  * Валидация на клиенте — это про удобство: сервер всё равно перепроверит
43
47
  * обязательность и наличие канала связи. Возвращаем ошибки по id поля плюс
44
48
  * общую ошибку формы.
45
49
  */
46
- export declare const validate: ({ form, values, phoneValid, t, }: ValidateContext) => {
50
+ export declare const validate: ({ form, values, phoneValid, t, fieldIds, requireContact, }: ValidateContext) => {
47
51
  errors: Record<string, string>;
48
52
  formError?: string;
49
53
  };
@@ -101,11 +101,13 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
101
101
  * обязательность и наличие канала связи. Возвращаем ошибки по id поля плюс
102
102
  * общую ошибку формы.
103
103
  */
104
- const validate = ({ form, values, phoneValid, t, }) => {
104
+ const validate = ({ form, values, phoneValid, t, fieldIds, requireContact = true, }) => {
105
105
  const errors = {};
106
106
  let hasPhone = false;
107
107
  let hasEmail = false;
108
108
  for (const field of form.fields) {
109
+ if (fieldIds && !fieldIds.has(field.id))
110
+ continue;
109
111
  if (!(0, exports.isVisible)(field, values))
110
112
  continue;
111
113
  const value = values[field.id];
@@ -133,7 +135,9 @@ const validate = ({ form, values, phoneValid, t, }) => {
133
135
  }
134
136
  // Заявку без телефона и почты обработать нельзя — бэкенд её отклонит,
135
137
  // так что честнее сказать это до отправки.
136
- const formError = !hasPhone && !hasEmail && !Object.keys(errors).length ? t('lead.needContact') : undefined;
138
+ const formError = requireContact && !hasPhone && !hasEmail && !Object.keys(errors).length
139
+ ? t('lead.needContact')
140
+ : undefined;
137
141
  return { errors, formError };
138
142
  };
139
143
  exports.validate = validate;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const lead_field_type_enum_1 = require("@escapenavigator/types/dist/lead/enums/lead-field-type.enum");
4
+ const lib_1 = require("./lib");
5
+ const form = {
6
+ fields: [
7
+ {
8
+ id: 'occasion',
9
+ type: lead_field_type_enum_1.LeadFieldTypeEnum.RADIO,
10
+ required: true,
11
+ enabled: true,
12
+ order: 0,
13
+ options: [],
14
+ i18n: {},
15
+ },
16
+ {
17
+ id: 'phone',
18
+ type: lead_field_type_enum_1.LeadFieldTypeEnum.PHONE,
19
+ required: true,
20
+ enabled: true,
21
+ order: 1,
22
+ i18n: {},
23
+ },
24
+ ],
25
+ };
26
+ describe('lead form step validation', () => {
27
+ it('validates only planning fields before opening the contacts step', () => {
28
+ const result = (0, lib_1.validate)({
29
+ form,
30
+ values: { occasion: '', phone: '' },
31
+ phoneValid: false,
32
+ t: (key) => key,
33
+ fieldIds: new Set(['occasion']),
34
+ requireContact: false,
35
+ });
36
+ expect(result).toEqual({
37
+ errors: { occasion: 'lead.required' },
38
+ formError: undefined,
39
+ });
40
+ });
41
+ });
@@ -26,6 +26,15 @@ const DEFAULTS = {
26
26
  'lead.guestsMinus': 'One guest less',
27
27
  'lead.guestsPlus': 'One guest more',
28
28
  'lead.timeUndecided': 'Not sure about the time yet',
29
+ 'lead.stepPlanning': 'Your preferences',
30
+ 'lead.stepContacts': 'Contacts',
31
+ 'lead.stepCounter': 'Step {{current}} of 2 · {{label}}',
32
+ 'lead.stepContactsTitle': 'How can we contact you?',
33
+ 'lead.stepContactsHint': 'Leave your contacts and preferred date — we will confirm the details.',
34
+ 'lead.next': 'Next — contacts',
35
+ 'lead.back': 'Back',
36
+ 'lead.selection': 'Your choices',
37
+ 'lead.edit': 'Edit',
29
38
  'lead.close': 'Close',
30
39
  'lead.another': 'Send another request',
31
40
  'lead.footnote': 'We will contact you and suggest options. A request is not a booking yet.',
@@ -41,6 +50,15 @@ const DEFAULTS = {
41
50
  const RU_DEFAULTS = {
42
51
  'lead.footnote': 'Менеджер свяжется с вами и предложит варианты. Заявка пока не бронирует игру.',
43
52
  'lead.timeUndecided': 'Пока не определился со временем',
53
+ 'lead.stepPlanning': 'Ваши пожелания',
54
+ 'lead.stepContacts': 'Контакты',
55
+ 'lead.stepCounter': 'Шаг {{current}} из 2 · {{label}}',
56
+ 'lead.stepContactsTitle': 'Как с вами связаться?',
57
+ 'lead.stepContactsHint': 'Оставьте контакты, желаемую дату и время — мы уточним детали.',
58
+ 'lead.next': 'Далее — контакты',
59
+ 'lead.back': 'Назад',
60
+ 'lead.selection': 'Вы выбрали',
61
+ 'lead.edit': 'Изменить',
44
62
  };
45
63
  const useLeadT = () => {
46
64
  var _a;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escapenavigator/hooks",
3
- "version": "2.1.23",
3
+ "version": "2.1.24",
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.1.23",
18
- "@escapenavigator/types": "^2.1.23",
19
- "@escapenavigator/utils": "^2.1.23",
17
+ "@escapenavigator/services": "^2.1.24",
18
+ "@escapenavigator/types": "^2.1.24",
19
+ "@escapenavigator/utils": "^2.1.24",
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": "533e344e432372b89b80d81c0ce35bc27463b677"
35
+ "gitHead": "153992d51e958b46acefa16ab49c9a0c1f727150"
36
36
  }