@escapenavigator/hooks 2.1.22 → 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.
@@ -15,11 +15,15 @@ type BuildConsentUrlsParams = {
15
15
  export type ConsentUrls = {
16
16
  agreementUrl: string;
17
17
  policyUrl: string;
18
+ /** Согласие на обработку ПДн площадки (нужно только площадкам из РФ). */
19
+ pdConsentUrl: string;
18
20
  };
19
21
  /**
20
22
  * Строит публичные ссылки для блока согласий:
21
23
  * - агримент: `/company/:slug/terms-of-services` (пусто, если нет slug);
22
- * - политика: `/documents/privacy_policy/:language`.
24
+ * - политика: `/company/:slug/privacy-policy` — оператор ПДн площадка,
25
+ * без slug (или без своей политики) — `/documents/privacy_policy/:language`;
26
+ * - согласие на ПДн: `/company/:slug/personal-data-consent`.
23
27
  */
24
28
  export declare const buildConsentUrls: ({ baseUrl, slug, language, }: BuildConsentUrlsParams) => ConsentUrls;
25
29
  export {};
@@ -4,14 +4,20 @@ exports.buildConsentUrls = void 0;
4
4
  /**
5
5
  * Строит публичные ссылки для блока согласий:
6
6
  * - агримент: `/company/:slug/terms-of-services` (пусто, если нет slug);
7
- * - политика: `/documents/privacy_policy/:language`.
7
+ * - политика: `/company/:slug/privacy-policy` — оператор ПДн площадка,
8
+ * без slug (или без своей политики) — `/documents/privacy_policy/:language`;
9
+ * - согласие на ПДн: `/company/:slug/personal-data-consent`.
8
10
  */
9
11
  const buildConsentUrls = ({ baseUrl, slug, language, }) => {
10
12
  const base = (baseUrl || '').replace(/\/+$/, '');
11
13
  const lang = language || 'en';
14
+ const company = slug ? `${base}/company/${slug}` : '';
12
15
  return {
13
- agreementUrl: slug ? `${base}/company/${slug}/terms-of-services` : '',
14
- policyUrl: `${base}/documents/privacy_policy/${lang}`,
16
+ agreementUrl: company ? `${company}/terms-of-services` : '',
17
+ policyUrl: company
18
+ ? `${company}/privacy-policy`
19
+ : `${base}/documents/privacy_policy/${lang}`,
20
+ pdConsentUrl: company ? `${company}/personal-data-consent` : '',
15
21
  };
16
22
  };
17
23
  exports.buildConsentUrls = buildConsentUrls;
@@ -2,16 +2,24 @@ import React from 'react';
2
2
  import { buildConsentUrls, ConsentUrls } from './build-consent-urls';
3
3
  export type { ConsentUrls };
4
4
  export { buildConsentUrls };
5
- export type ConsentField = 'agreeTerms' | 'agreePolicy' | 'sendAds';
5
+ export type ConsentField = 'agreeTerms' | 'agreePolicy' | 'sendAds' | 'pdConsent';
6
6
  export type ConsentValues = Record<ConsentField, boolean>;
7
7
  export declare const INITIAL_CONSENT_VALUES: ConsentValues;
8
- /** Обязательные согласия отмечены — можно подтверждать заказ/подпись. */
8
+ /**
9
+ * Обязательные согласия отмечены — можно подтверждать заказ/подпись.
10
+ * `pdConsent` без отдельной галочки (вне РФ) ставится вместе с основной.
11
+ */
9
12
  export declare const areRequiredConsentsAccepted: (values: ConsentValues) => boolean;
10
13
  type Props = {
11
14
  /** Ссылка на клиентское соглашение оператора. */
12
15
  agreementUrl: string;
13
16
  /** Ссылка на политику конфиденциальности. */
14
17
  policyUrl: string;
18
+ /**
19
+ * Ссылка на согласие на обработку ПДн. Передаётся только площадкам из РФ:
20
+ * по 152-ФЗ согласие — отдельная галочка, не смешанная с другими документами.
21
+ */
22
+ pdConsentUrl?: string;
15
23
  values: ConsentValues;
16
24
  onChange: (field: ConsentField, checked: boolean) => void;
17
25
  /**
@@ -8,6 +8,7 @@ exports.ConsentCheckboxes = exports.areRequiredConsentsAccepted = exports.INITIA
8
8
  const ui_1 = require("@alphakits/ui");
9
9
  const react_1 = __importDefault(require("react"));
10
10
  const react_i18next_1 = require("react-i18next");
11
+ const personal_data_consent_1 = require("../personal-data-consent");
11
12
  const build_consent_urls_1 = require("./build-consent-urls");
12
13
  Object.defineProperty(exports, "buildConsentUrls", { enumerable: true, get: function () { return build_consent_urls_1.buildConsentUrls; } });
13
14
  const index_module_css_1 = __importDefault(require("./index.module.css"));
@@ -15,20 +16,28 @@ exports.INITIAL_CONSENT_VALUES = {
15
16
  agreeTerms: false,
16
17
  agreePolicy: false,
17
18
  sendAds: false,
19
+ pdConsent: false,
18
20
  };
19
- /** Обязательные согласия отмечены — можно подтверждать заказ/подпись. */
20
- const areRequiredConsentsAccepted = (values) => values.agreeTerms && values.agreePolicy;
21
+ /**
22
+ * Обязательные согласия отмечены — можно подтверждать заказ/подпись.
23
+ * `pdConsent` без отдельной галочки (вне РФ) ставится вместе с основной.
24
+ */
25
+ const areRequiredConsentsAccepted = (values) => values.agreeTerms && values.agreePolicy && values.pdConsent;
21
26
  exports.areRequiredConsentsAccepted = areRequiredConsentsAccepted;
22
27
  const labelColor = (invalid) => invalid ? 'negative' : 'secondary';
23
- const ConsentCheckboxes = ({ agreementUrl, policyUrl, values, onChange, hideMarketing, showRequiredError, }) => {
28
+ const ConsentCheckboxes = ({ agreementUrl, policyUrl, pdConsentUrl, values, onChange, hideMarketing, showRequiredError, }) => {
24
29
  const { t } = (0, react_i18next_1.useTranslation)('common');
30
+ const separatePdConsent = !!pdConsentUrl;
25
31
  // Агримент + политику объединили в одну обязательную галочку: меньше
26
32
  // «трения» на экране оплаты. Обе ссылки внутри одной переводимой строки.
27
33
  const requiredAccepted = values.agreeTerms && values.agreePolicy;
28
34
  const requiredInvalid = !!showRequiredError && !requiredAccepted;
35
+ const pdConsentInvalid = !!showRequiredError && !values.pdConsent;
29
36
  const setRequired = (checked) => {
30
37
  onChange('agreeTerms', checked);
31
38
  onChange('agreePolicy', checked);
39
+ if (!separatePdConsent)
40
+ onChange('pdConsent', checked);
32
41
  };
33
42
  return (react_1.default.createElement("div", { className: [index_module_css_1.default.wrapper, showRequiredError ? index_module_css_1.default.shake : ''].join(' ').trim() },
34
43
  react_1.default.createElement(ui_1.FlexColumns, { columns: 1, gr: 8 },
@@ -37,6 +46,8 @@ const ConsentCheckboxes = ({ agreementUrl, policyUrl, values, onChange, hideMark
37
46
  react_1.default.createElement(ui_1.Link, { key: 'agreement', size: 's', target: '_blank', rel: 'noreferrer', className: index_module_css_1.default.link, href: agreementUrl }),
38
47
  react_1.default.createElement(ui_1.Link, { key: 'policy', size: 's', target: '_blank', rel: 'noreferrer', className: index_module_css_1.default.link, href: policyUrl }),
39
48
  ] })) }),
49
+ separatePdConsent && (react_1.default.createElement(ui_1.Checkbox, { size: 's', align: 'start', block: true, checked: values.pdConsent, onChange: (_e, { checked }) => onChange('pdConsent', checked), label: react_1.default.createElement(ui_1.Typography.Text, { view: 'caps', color: labelColor(pdConsentInvalid) },
50
+ react_1.default.createElement(personal_data_consent_1.PersonalDataConsentLabel, { consentUrl: pdConsentUrl })) })),
40
51
  !hideMarketing && (react_1.default.createElement(ui_1.Checkbox, { size: 's', align: 'start', block: true, checked: values.sendAds, onChange: (_e, { checked }) => onChange('sendAds', checked), label: react_1.default.createElement(ui_1.Typography.Text, { view: 'caps', color: 'secondary' }, t('consent.marketing', "I'd like to receive special offers, promotions and news — no spam, unsubscribe anytime")) })),
41
52
  showRequiredError && (react_1.default.createElement(ui_1.Typography.Text, { view: 'caps', color: 'negative' }, t('consent.required', 'Please accept the required agreements to continue'))))));
42
53
  };
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export * from './functional-popover';
3
3
  export * from './input-tel';
4
4
  export * from './lazy-with-retry';
5
5
  export * from './lead-form';
6
+ export * from './personal-data-consent';
6
7
  export * from './players-and-variation-picker';
7
8
  export * from './use-debounced-value';
8
9
  export * from './use-localstore-state';
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ __exportStar(require("./functional-popover"), exports);
19
19
  __exportStar(require("./input-tel"), exports);
20
20
  __exportStar(require("./lazy-with-retry"), exports);
21
21
  __exportStar(require("./lead-form"), exports);
22
+ __exportStar(require("./personal-data-consent"), exports);
22
23
  __exportStar(require("./players-and-variation-picker"), exports);
23
24
  __exportStar(require("./use-debounced-value"), exports);
24
25
  __exportStar(require("./use-localstore-state"), exports);
@@ -9,6 +9,7 @@ const MinusM_1 = __importDefault(require("@alphakits/icons/dist/MinusM"));
9
9
  const PlusM_1 = __importDefault(require("@alphakits/icons/dist/PlusM"));
10
10
  const ui_1 = require("@alphakits/ui");
11
11
  const lead_field_type_enum_1 = require("@escapenavigator/types/dist/lead/enums/lead-field-type.enum");
12
+ const lead_time_constants_1 = require("@escapenavigator/types/dist/lead/lead-time.constants");
12
13
  const react_1 = __importDefault(require("react"));
13
14
  const input_tel_1 = require("../input-tel");
14
15
  const index_module_css_1 = __importDefault(require("./index.module.css"));
@@ -25,6 +26,20 @@ const buildTimeOptions = () => {
25
26
  return out;
26
27
  };
27
28
  const TIME_OPTIONS = buildTimeOptions();
29
+ /** Больше трёх вариантов в ряд пилюль не влезает на мобиле — раскладываем плитками. */
30
+ const TILES_FROM = 4;
31
+ /**
32
+ * Радио и чекбоксы площадки — кнопками-вариантами: один тап вместо попадания
33
+ * в кружок, и форма читается как выбор, а не как анкета.
34
+ */
35
+ const ChoiceGroup = ({ label, options, isSelected, onToggle, hint, error, }) => (react_1.default.createElement(ui_1.FlexColumns, { columns: 1, gr: 8 },
36
+ react_1.default.createElement(ui_1.Typography.Text, { view: 'primary-small', weight: 'medium', color: 'primary' }, label),
37
+ react_1.default.createElement("div", { role: 'group', "aria-label": label, className: options.length >= TILES_FROM ? index_module_css_1.default.choiceTiles : index_module_css_1.default.choicePills }, options.map((option) => {
38
+ const selected = isSelected(option.id);
39
+ 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) }, option.label));
40
+ })),
41
+ hint,
42
+ !!error && (react_1.default.createElement(ui_1.Typography.Text, { view: 'caps', color: 'negative' }, error))));
28
43
  const LeadField = ({ field, value, error, language, profileCountry, questrooms, locations, onChange, onPhoneValidChange, }) => {
29
44
  var _a, _b;
30
45
  const t = (0, use_lead_t_1.useLeadT)();
@@ -41,6 +56,10 @@ const LeadField = ({ field, value, error, language, profileCountry, questrooms,
41
56
  hint));
42
57
  };
43
58
  const activeOptions = (field.options || []).filter((option) => option.active !== false);
59
+ const choiceOptions = activeOptions.map((option) => ({
60
+ id: option.id,
61
+ label: (0, lib_1.optionLabel)(option, language, option.id),
62
+ }));
44
63
  const referenceOptions = (items) => items.map((item) => ({ key: String(item.id), content: item.title }));
45
64
  switch (field.type) {
46
65
  case lead_field_type_enum_1.LeadFieldTypeEnum.PHONE:
@@ -53,9 +72,24 @@ const LeadField = ({ field, value, error, language, profileCountry, questrooms,
53
72
  case lead_field_type_enum_1.LeadFieldTypeEnum.DATE:
54
73
  return withHint(react_1.default.createElement(ui_1.CalendarInput, { block: true, label: label, lang: language, value: String(value || ''), error: error, onChange: (_e, payload) => onChange(payload.value) }));
55
74
  case lead_field_type_enum_1.LeadFieldTypeEnum.TIME:
56
- return withHint(react_1.default.createElement(ui_1.Select, { block: true, optionsListWidth: 'field', label: label, options: value && !TIME_OPTIONS.some((option) => option.key === String(value))
57
- ? [{ key: String(value), content: String(value) }, ...TIME_OPTIONS]
58
- : TIME_OPTIONS, selected: String(value || ''), error: error, onChange: ({ selected }) => onChange(selected === null || selected === void 0 ? void 0 : selected.key) }));
75
+ return (react_1.default.createElement(ui_1.FlexColumns, { columns: 1, gr: 8 },
76
+ react_1.default.createElement(ui_1.Typography.Text, { view: 'primary-small', weight: 'medium', color: 'primary' }, label),
77
+ react_1.default.createElement("div", { className: index_module_css_1.default.timeChoices },
78
+ react_1.default.createElement(ui_1.Select, { block: true, optionsListWidth: 'field', options: value &&
79
+ value !== lead_time_constants_1.LEAD_TIME_UNDECIDED_VALUE &&
80
+ !TIME_OPTIONS.some((option) => option.key === String(value))
81
+ ? [
82
+ { key: String(value), content: String(value) },
83
+ ...TIME_OPTIONS,
84
+ ]
85
+ : TIME_OPTIONS, selected: value === lead_time_constants_1.LEAD_TIME_UNDECIDED_VALUE ? '' : String(value || ''), error: error, onChange: ({ selected }) => onChange(selected === null || selected === void 0 ? void 0 : selected.key) }),
86
+ react_1.default.createElement("button", { type: 'button', className: index_module_css_1.default.timeUndecided, "data-selected": value === lead_time_constants_1.LEAD_TIME_UNDECIDED_VALUE, "aria-pressed": value === lead_time_constants_1.LEAD_TIME_UNDECIDED_VALUE, onClick: () => onChange(lead_time_constants_1.LEAD_TIME_UNDECIDED_VALUE) },
87
+ react_1.default.createElement("span", { className: index_module_css_1.default.timeUndecidedIcon, "aria-hidden": 'true' },
88
+ react_1.default.createElement("svg", { viewBox: '0 0 24 24', fill: 'none', "aria-hidden": 'true' },
89
+ react_1.default.createElement("circle", { cx: '12', cy: '12', r: '8.25' }),
90
+ react_1.default.createElement("path", { d: 'M12 7.75v4.6l3.05 1.8' }))),
91
+ react_1.default.createElement("span", null, t('lead.timeUndecided')))),
92
+ hint));
59
93
  case lead_field_type_enum_1.LeadFieldTypeEnum.GUESTS: {
60
94
  const min = (_a = field.min) !== null && _a !== void 0 ? _a : 1;
61
95
  const max = (_b = field.max) !== null && _b !== void 0 ? _b : 30;
@@ -78,20 +112,12 @@ const LeadField = ({ field, value, error, language, profileCountry, questrooms,
78
112
  content: (0, lib_1.optionLabel)(option, language, option.id),
79
113
  })), selected: String(value || ''), error: error, onChange: ({ selected }) => onChange(selected === null || selected === void 0 ? void 0 : selected.key) }));
80
114
  case lead_field_type_enum_1.LeadFieldTypeEnum.RADIO:
81
- return (react_1.default.createElement(ui_1.FlexColumns, { columns: 1, gr: 8 },
82
- react_1.default.createElement(ui_1.Typography.Text, { view: 'primary-small', weight: 'medium', color: 'primary' }, label),
83
- activeOptions.map((option) => (react_1.default.createElement(ui_1.Radio, { key: option.id, label: (0, lib_1.optionLabel)(option, language, option.id), checked: value === option.id, onChange: () => onChange(option.id) }))),
84
- hint,
85
- !!error && (react_1.default.createElement(ui_1.Typography.Text, { view: 'caps', color: 'negative' }, error))));
115
+ return (react_1.default.createElement(ChoiceGroup, { label: label, options: choiceOptions, isSelected: (id) => value === id, onToggle: (id) => onChange(id), hint: hint, error: error }));
86
116
  case lead_field_type_enum_1.LeadFieldTypeEnum.CHECKBOX: {
87
117
  const selected = Array.isArray(value) ? value : [];
88
- return (react_1.default.createElement(ui_1.FlexColumns, { columns: 1, gr: 8 },
89
- react_1.default.createElement(ui_1.Typography.Text, { view: 'primary-small', weight: 'medium', color: 'primary' }, label),
90
- activeOptions.map((option) => (react_1.default.createElement(ui_1.Checkbox, { key: option.id, label: (0, lib_1.optionLabel)(option, language, option.id), checked: selected.includes(option.id), onChange: () => onChange(selected.includes(option.id)
91
- ? selected.filter((item) => item !== option.id)
92
- : [...selected, option.id]) }))),
93
- hint,
94
- !!error && (react_1.default.createElement(ui_1.Typography.Text, { view: 'caps', color: 'negative' }, error))));
118
+ return (react_1.default.createElement(ChoiceGroup, { label: label, options: choiceOptions, isSelected: (id) => selected.includes(id), onToggle: (id) => onChange(selected.includes(id)
119
+ ? selected.filter((item) => item !== id)
120
+ : [...selected, id]), hint: hint, error: error }));
95
121
  }
96
122
  case lead_field_type_enum_1.LeadFieldTypeEnum.QUESTROOM:
97
123
  case lead_field_type_enum_1.LeadFieldTypeEnum.LOCATION:
@@ -10,6 +10,7 @@ export type LeadFormSubmitPayload = {
10
10
  answers: SubmitLeadAnswerDto[];
11
11
  source: LeadSourceEnum;
12
12
  trap?: string;
13
+ pdConsent?: boolean;
13
14
  };
14
15
  /**
15
16
  * Отправку делает вызывающая сторона: из виджета заявка уходит с
@@ -41,6 +42,10 @@ type Props = {
41
42
  doneLabel?: string;
42
43
  /** Контейнер прячет свою шапку, чтобы экран успеха не конкурировал с названием. */
43
44
  onSuccess?: () => void;
45
+ /** Политика площадки в отношении ПДн — ссылка под формой. */
46
+ policyUrl?: string;
47
+ /** Документ согласия на обработку ПДн (галочка есть только в РФ). */
48
+ pdConsentUrl?: string;
44
49
  };
45
50
  export declare const LeadForm: React.FC<Props>;
46
51
  export {};
@@ -48,13 +48,32 @@ 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"));
53
+ const personal_data_consent_1 = require("../personal-data-consent");
52
54
  const index_module_css_1 = __importDefault(require("./index.module.css"));
53
55
  const lead_field_1 = require("./lead-field");
54
56
  const lib_1 = require("./lib");
55
57
  const use_lead_t_1 = require("./use-lead-t");
56
- const LeadForm = ({ form, source, language, profileCountry, submit, prefill, contextNote, withHeader = true, onDone, doneLabel, onSuccess, }) => {
58
+ const ArrowRight = () => (react_1.default.createElement("svg", { width: '18', height: '18', viewBox: '0 0 24 24', fill: 'none', "aria-hidden": 'true' },
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
+ ]);
71
+ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, contextNote, withHeader = true, onDone, doneLabel, onSuccess, policyUrl, pdConsentUrl, }) => {
57
72
  const t = (0, use_lead_t_1.useLeadT)();
73
+ const consentRequired = (0, personal_data_consent_1.requiresPersonalDataConsent)(profileCountry);
74
+ const [consent, setConsent] = (0, react_1.useState)(false);
75
+ const [showConsentError, setShowConsentError] = (0, react_1.useState)(false);
76
+ const [step, setStep] = (0, react_1.useState)(1);
58
77
  const [values, setValues] = (0, react_1.useState)(() => (0, lib_1.buildInitialValues)(form, prefill));
59
78
  const [errors, setErrors] = (0, react_1.useState)({});
60
79
  const [formError, setFormError] = (0, react_1.useState)();
@@ -68,14 +87,74 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
68
87
  setErrors((prev) => (prev[fieldId] ? Object.assign(Object.assign({}, prev), { [fieldId]: '' }) : prev));
69
88
  setFormError(undefined);
70
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
+ };
71
145
  const handleSubmit = () => __awaiter(void 0, void 0, void 0, function* () {
72
146
  var _a;
73
147
  if (sending)
74
148
  return;
75
149
  const result = (0, lib_1.validate)({ form, values, phoneValid, t });
76
- if (Object.keys(result.errors).length || result.formError) {
150
+ const consentMissing = consentRequired && !consent;
151
+ setShowConsentError(consentMissing);
152
+ if (Object.keys(result.errors).length || result.formError || consentMissing) {
77
153
  setErrors(result.errors);
78
154
  setFormError(result.formError);
155
+ if (Object.keys(result.errors).some((fieldId) => planningFieldIds.has(fieldId))) {
156
+ setStep(1);
157
+ }
79
158
  return;
80
159
  }
81
160
  setSending(true);
@@ -85,6 +164,7 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
85
164
  answers: (0, lib_1.buildAnswers)(form, values, contextNote),
86
165
  source,
87
166
  trap: trap || undefined,
167
+ pdConsent: consentRequired ? consent : undefined,
88
168
  });
89
169
  setSending(false);
90
170
  if (!(response === null || response === void 0 ? void 0 : response.data)) {
@@ -109,17 +189,42 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
109
189
  }
110
190
  const title = (0, lib_1.localized)(form.clientTitle, language) || t('lead.title');
111
191
  const description = (0, lib_1.localized)(form.description, language);
112
- return (react_1.default.createElement(ui_1.FlexColumns, { columns: 1, gr: 16 },
113
- withHeader && (react_1.default.createElement(ui_1.FlexColumns, { columns: 1, gr: 4 },
114
- react_1.default.createElement(ui_1.Typography.Title, { tag: 'h1', view: 'small', weight: 'bold' }, title),
115
- !!description && (react_1.default.createElement(ui_1.Typography.Text, { view: 'primary-small', color: 'secondary' }, description)))),
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;
195
+ return (react_1.default.createElement("div", { className: index_module_css_1.default.form },
196
+ withHeader && (react_1.default.createElement("div", { className: index_module_css_1.default.header },
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)))),
116
210
  !!formError && react_1.default.createElement(ui_1.ToastPlate, { view: 'negative' }, formError),
117
- form.fields
118
- .filter((field) => (0, lib_1.isVisible)(field, values))
119
- .map((field) => (react_1.default.createElement(lead_field_1.LeadField, { key: field.id, 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 }))),
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 },
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 }))))),
120
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) }),
121
- react_1.default.createElement(ui_1.Button, { view: 'primary', size: 's', block: true, loading: sending, onClick: handleSubmit }, sending
122
- ? t('lead.sending')
123
- : (0, lib_1.localized)(form.submitLabel, language) || t('lead.submit'))));
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,
220
+ (consentRequired || !!policyUrl) && (react_1.default.createElement(personal_data_consent_1.PersonalDataConsent, { required: consentRequired, consentUrl: pdConsentUrl || '', policyUrl: policyUrl || '', checked: consent, showError: showConsentError, onChange: (checked) => {
221
+ setConsent(checked);
222
+ if (checked)
223
+ setShowConsentError(false);
224
+ } })),
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
226
+ ? t('lead.sending')
227
+ : (0, lib_1.localized)(form.submitLabel, language) || t('lead.submit')),
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')))))));
124
229
  };
125
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
+ });
@@ -25,8 +25,19 @@ const DEFAULTS = {
25
25
  'lead.unavailable': 'The request form is not available right now',
26
26
  'lead.guestsMinus': 'One guest less',
27
27
  'lead.guestsPlus': 'One guest more',
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',
28
38
  'lead.close': 'Close',
29
39
  'lead.another': 'Send another request',
40
+ 'lead.footnote': 'We will contact you and suggest options. A request is not a booking yet.',
30
41
  // Кнопка «оставить заявку» рядом с расписанием.
31
42
  'lead.ctaTitle': 'Cannot find a suitable time?',
32
43
  'lead.ctaHint': 'Leave a request and we will arrange the booking for you',
@@ -35,8 +46,24 @@ const DEFAULTS = {
35
46
  'lead.certCtaTitle': 'Need help with a gift?',
36
47
  'lead.certCtaHint': 'Leave a request and we will pick a certificate for you',
37
48
  };
49
+ /** Новые ключи, которых ещё нет в сервисе: русский аудитории показываем сразу. */
50
+ const RU_DEFAULTS = {
51
+ 'lead.footnote': 'Менеджер свяжется с вами и предложит варианты. Заявка пока не бронирует игру.',
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': 'Изменить',
62
+ };
38
63
  const useLeadT = () => {
39
- const { t } = (0, react_i18next_1.useTranslation)();
40
- return (0, react_1.useCallback)((key, options) => { var _a; return t(key, Object.assign({ defaultValue: (_a = DEFAULTS[key]) !== null && _a !== void 0 ? _a : key }, options)); }, [t]);
64
+ var _a;
65
+ const { t, i18n } = (0, react_i18next_1.useTranslation)();
66
+ const isRuLang = (_a = i18n.language) === null || _a === void 0 ? void 0 : _a.startsWith('ru');
67
+ return (0, react_1.useCallback)((key, options) => t(key, Object.assign({ defaultValue: (isRuLang && RU_DEFAULTS[key]) || DEFAULTS[key] || key }, options)), [t, isRuLang]);
41
68
  };
42
69
  exports.useLeadT = useLeadT;
@@ -0,0 +1,46 @@
1
+ import { requiresPersonalDataConsent } from '@escapenavigator/utils/dist/personal-data-consent';
2
+ import React from 'react';
3
+ export { requiresPersonalDataConsent };
4
+ /**
5
+ * Ключи пока не заведены в translations-сервисе, а блок видят в основном
6
+ * русскоязычные посетители — английский дефолт им показывать нельзя.
7
+ */
8
+ declare const DEFAULTS: {
9
+ readonly 'consent.personalData': {
10
+ readonly ru: "Я даю <0>согласие на обработку персональных данных</0>";
11
+ readonly en: "I give my <0>consent to the processing of personal data</0>";
12
+ };
13
+ readonly 'consent.personalDataRequired': {
14
+ readonly ru: "Подтвердите согласие на обработку персональных данных";
15
+ readonly en: "Please confirm your consent to the processing of personal data";
16
+ };
17
+ readonly 'consent.privacyNotice': {
18
+ readonly ru: "Данные обрабатываются в соответствии с <0>политикой конфиденциальности</0>";
19
+ readonly en: "Your data is processed in accordance with the <0>privacy policy</0>";
20
+ };
21
+ };
22
+ type DefaultKey = keyof typeof DEFAULTS;
23
+ export declare const usePersonalDataConsentT: () => {
24
+ t: import("i18next").TFunction<"common", undefined, "common">;
25
+ defaults: (key: DefaultKey) => "Я даю <0>согласие на обработку персональных данных</0>" | "I give my <0>consent to the processing of personal data</0>" | "Подтвердите согласие на обработку персональных данных" | "Please confirm your consent to the processing of personal data" | "Данные обрабатываются в соответствии с <0>политикой конфиденциальности</0>" | "Your data is processed in accordance with the <0>privacy policy</0>";
26
+ };
27
+ /** Текст галочки согласия со ссылкой на сам документ согласия. */
28
+ export declare const PersonalDataConsentLabel: React.FC<{
29
+ consentUrl: string;
30
+ }>;
31
+ type Props = {
32
+ /**
33
+ * Нужна отдельная галочка (152-ФЗ, площадки из РФ). Иначе (GDPR) хватает
34
+ * уведомления со ссылкой на политику: данные нужны для заявки/договора.
35
+ */
36
+ required: boolean;
37
+ /** Документ согласия площадки. */
38
+ consentUrl: string;
39
+ /** Политика площадки в отношении ПДн. */
40
+ policyUrl: string;
41
+ checked: boolean;
42
+ onChange: (checked: boolean) => void;
43
+ /** Клиент пытался отправить без галочки: красим, трясём и пишем почему. */
44
+ showError?: boolean;
45
+ };
46
+ export declare const PersonalDataConsent: React.FC<Props>;
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ /* eslint-disable import/no-extraneous-dependencies */
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.PersonalDataConsent = exports.PersonalDataConsentLabel = exports.usePersonalDataConsentT = exports.requiresPersonalDataConsent = void 0;
8
+ const ui_1 = require("@alphakits/ui");
9
+ const personal_data_consent_1 = require("@escapenavigator/utils/dist/personal-data-consent");
10
+ Object.defineProperty(exports, "requiresPersonalDataConsent", { enumerable: true, get: function () { return personal_data_consent_1.requiresPersonalDataConsent; } });
11
+ const react_1 = __importDefault(require("react"));
12
+ const react_i18next_1 = require("react-i18next");
13
+ const index_module_css_1 = __importDefault(require("./index.module.css"));
14
+ /**
15
+ * Ключи пока не заведены в translations-сервисе, а блок видят в основном
16
+ * русскоязычные посетители — английский дефолт им показывать нельзя.
17
+ */
18
+ const DEFAULTS = {
19
+ 'consent.personalData': {
20
+ ru: 'Я даю <0>согласие на обработку персональных данных</0>',
21
+ en: 'I give my <0>consent to the processing of personal data</0>',
22
+ },
23
+ 'consent.personalDataRequired': {
24
+ ru: 'Подтвердите согласие на обработку персональных данных',
25
+ en: 'Please confirm your consent to the processing of personal data',
26
+ },
27
+ 'consent.privacyNotice': {
28
+ ru: 'Данные обрабатываются в соответствии с <0>политикой конфиденциальности</0>',
29
+ en: 'Your data is processed in accordance with the <0>privacy policy</0>',
30
+ },
31
+ };
32
+ const usePersonalDataConsentT = () => {
33
+ var _a;
34
+ const { t, i18n } = (0, react_i18next_1.useTranslation)('common');
35
+ const lang = ((_a = i18n.language) === null || _a === void 0 ? void 0 : _a.startsWith('ru')) ? 'ru' : 'en';
36
+ return {
37
+ t,
38
+ defaults: (key) => DEFAULTS[key][lang],
39
+ };
40
+ };
41
+ exports.usePersonalDataConsentT = usePersonalDataConsentT;
42
+ const DocLink = ({ href, children }) => (react_1.default.createElement(ui_1.Link, { size: 's', target: '_blank', rel: 'noreferrer', className: index_module_css_1.default.link, href: href }, children));
43
+ const TransWithLink = ({ i18nKey, href }) => {
44
+ const { t, defaults } = (0, exports.usePersonalDataConsentT)();
45
+ return (react_1.default.createElement(react_i18next_1.Trans, { t: t, ns: 'common', i18nKey: i18nKey, defaults: defaults(i18nKey), components: [react_1.default.createElement(DocLink, { key: 'doc', href: href })] }));
46
+ };
47
+ /** Текст галочки согласия со ссылкой на сам документ согласия. */
48
+ const PersonalDataConsentLabel = ({ consentUrl }) => (react_1.default.createElement(TransWithLink, { i18nKey: 'consent.personalData', href: consentUrl }));
49
+ exports.PersonalDataConsentLabel = PersonalDataConsentLabel;
50
+ const PersonalDataConsent = ({ required, consentUrl, policyUrl, checked, onChange, showError, }) => {
51
+ const { t, defaults } = (0, exports.usePersonalDataConsentT)();
52
+ const invalid = required && !!showError && !checked;
53
+ const notice = (react_1.default.createElement(ui_1.Typography.Text, { view: 'caps', color: 'secondary' },
54
+ react_1.default.createElement(TransWithLink, { i18nKey: 'consent.privacyNotice', href: policyUrl })));
55
+ if (!required)
56
+ return react_1.default.createElement("div", { className: index_module_css_1.default.wrapper }, notice);
57
+ return (react_1.default.createElement("div", { className: [index_module_css_1.default.wrapper, invalid ? index_module_css_1.default.shake : ''].join(' ').trim() },
58
+ react_1.default.createElement(ui_1.FlexColumns, { columns: 1, gr: 6 },
59
+ react_1.default.createElement(ui_1.Checkbox, { size: 's', align: 'start', block: true, checked: checked, onChange: (_e, payload) => onChange(!!(payload === null || payload === void 0 ? void 0 : payload.checked)), label: react_1.default.createElement(ui_1.Typography.Text, { view: 'caps', color: invalid ? 'negative' : 'secondary' },
60
+ react_1.default.createElement(exports.PersonalDataConsentLabel, { consentUrl: consentUrl })) }),
61
+ notice,
62
+ invalid && (react_1.default.createElement(ui_1.Typography.Text, { view: 'caps', color: 'negative' }, t('consent.personalDataRequired', {
63
+ defaultValue: defaults('consent.personalDataRequired'),
64
+ }))))));
65
+ };
66
+ exports.PersonalDataConsent = PersonalDataConsent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escapenavigator/hooks",
3
- "version": "2.1.22",
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.22",
18
- "@escapenavigator/types": "^2.1.22",
19
- "@escapenavigator/utils": "^2.1.22",
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": "a4d5c142ffa0a99936093b991f5ee87cf8359d7e"
35
+ "gitHead": "153992d51e958b46acefa16ab49c9a0c1f727150"
36
36
  }