@escapenavigator/hooks 2.1.21 → 2.1.23

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 {};
@@ -49,12 +49,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
49
49
  exports.LeadForm = void 0;
50
50
  const ui_1 = require("@alphakits/ui");
51
51
  const react_1 = __importStar(require("react"));
52
+ const personal_data_consent_1 = require("../personal-data-consent");
52
53
  const index_module_css_1 = __importDefault(require("./index.module.css"));
53
54
  const lead_field_1 = require("./lead-field");
54
55
  const lib_1 = require("./lib");
55
56
  const use_lead_t_1 = require("./use-lead-t");
56
- const LeadForm = ({ form, source, language, profileCountry, submit, prefill, contextNote, withHeader = true, onDone, doneLabel, onSuccess, }) => {
57
+ const ArrowRight = () => (react_1.default.createElement("svg", { width: '18', height: '18', viewBox: '0 0 24 24', fill: 'none', "aria-hidden": 'true' },
58
+ react_1.default.createElement("path", { d: 'M5 12h14m-6-6 6 6-6 6', stroke: 'currentColor', strokeWidth: '2', strokeLinecap: 'round', strokeLinejoin: 'round' })));
59
+ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, contextNote, withHeader = true, onDone, doneLabel, onSuccess, policyUrl, pdConsentUrl, }) => {
57
60
  const t = (0, use_lead_t_1.useLeadT)();
61
+ const consentRequired = (0, personal_data_consent_1.requiresPersonalDataConsent)(profileCountry);
62
+ const [consent, setConsent] = (0, react_1.useState)(false);
63
+ const [showConsentError, setShowConsentError] = (0, react_1.useState)(false);
58
64
  const [values, setValues] = (0, react_1.useState)(() => (0, lib_1.buildInitialValues)(form, prefill));
59
65
  const [errors, setErrors] = (0, react_1.useState)({});
60
66
  const [formError, setFormError] = (0, react_1.useState)();
@@ -73,7 +79,9 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
73
79
  if (sending)
74
80
  return;
75
81
  const result = (0, lib_1.validate)({ form, values, phoneValid, t });
76
- if (Object.keys(result.errors).length || result.formError) {
82
+ const consentMissing = consentRequired && !consent;
83
+ setShowConsentError(consentMissing);
84
+ if (Object.keys(result.errors).length || result.formError || consentMissing) {
77
85
  setErrors(result.errors);
78
86
  setFormError(result.formError);
79
87
  return;
@@ -85,6 +93,7 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
85
93
  answers: (0, lib_1.buildAnswers)(form, values, contextNote),
86
94
  source,
87
95
  trap: trap || undefined,
96
+ pdConsent: consentRequired ? consent : undefined,
88
97
  });
89
98
  setSending(false);
90
99
  if (!(response === null || response === void 0 ? void 0 : response.data)) {
@@ -109,17 +118,26 @@ const LeadForm = ({ form, source, language, profileCountry, submit, prefill, con
109
118
  }
110
119
  const title = (0, lib_1.localized)(form.clientTitle, language) || t('lead.title');
111
120
  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 },
121
+ return (react_1.default.createElement("div", { className: index_module_css_1.default.form },
122
+ 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' }),
114
124
  react_1.default.createElement(ui_1.Typography.Title, { tag: 'h1', view: 'small', weight: 'bold' }, title),
115
125
  !!description && (react_1.default.createElement(ui_1.Typography.Text, { view: 'primary-small', color: 'secondary' }, description)))),
116
126
  !!formError && react_1.default.createElement(ui_1.ToastPlate, { view: 'negative' }, formError),
117
- form.fields
127
+ react_1.default.createElement("div", { className: index_module_css_1.default.fields }, form.fields
118
128
  .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 }))),
129
+ .map((field) => (react_1.default.createElement("div", { key: field.id, className: index_module_css_1.default.fieldCard, "data-field-type": field.type },
130
+ 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
131
  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'))));
132
+ react_1.default.createElement("div", { className: index_module_css_1.default.footer },
133
+ (consentRequired || !!policyUrl) && (react_1.default.createElement(personal_data_consent_1.PersonalDataConsent, { required: consentRequired, consentUrl: pdConsentUrl || '', policyUrl: policyUrl || '', checked: consent, showError: showConsentError, onChange: (checked) => {
134
+ setConsent(checked);
135
+ if (checked)
136
+ setShowConsentError(false);
137
+ } })),
138
+ 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
+ ? t('lead.sending')
140
+ : (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')))));
124
142
  };
125
143
  exports.LeadForm = LeadForm;
@@ -25,8 +25,10 @@ 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',
28
29
  'lead.close': 'Close',
29
30
  'lead.another': 'Send another request',
31
+ 'lead.footnote': 'We will contact you and suggest options. A request is not a booking yet.',
30
32
  // Кнопка «оставить заявку» рядом с расписанием.
31
33
  'lead.ctaTitle': 'Cannot find a suitable time?',
32
34
  'lead.ctaHint': 'Leave a request and we will arrange the booking for you',
@@ -35,8 +37,15 @@ const DEFAULTS = {
35
37
  'lead.certCtaTitle': 'Need help with a gift?',
36
38
  'lead.certCtaHint': 'Leave a request and we will pick a certificate for you',
37
39
  };
40
+ /** Новые ключи, которых ещё нет в сервисе: русский аудитории показываем сразу. */
41
+ const RU_DEFAULTS = {
42
+ 'lead.footnote': 'Менеджер свяжется с вами и предложит варианты. Заявка пока не бронирует игру.',
43
+ 'lead.timeUndecided': 'Пока не определился со временем',
44
+ };
38
45
  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]);
46
+ var _a;
47
+ const { t, i18n } = (0, react_i18next_1.useTranslation)();
48
+ const isRuLang = (_a = i18n.language) === null || _a === void 0 ? void 0 : _a.startsWith('ru');
49
+ return (0, react_1.useCallback)((key, options) => t(key, Object.assign({ defaultValue: (isRuLang && RU_DEFAULTS[key]) || DEFAULTS[key] || key }, options)), [t, isRuLang]);
41
50
  };
42
51
  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;
@@ -103,9 +103,14 @@ export type PlayersAndVariationPickerProps = {
103
103
  /**
104
104
  * When provided, renders a Save button inside the picker. The composition
105
105
  * is buffered locally until the user saves.
106
+ *
107
+ * `variation` есть только если в этом открытии попапа кликнули другую
108
+ * опцию. Закрытие без сохранения его не запоминает — иначе следующая
109
+ * правка числа людей уезжала бы со старым тарифом.
106
110
  */
107
111
  onSave?: (data: LegacyCounts & {
108
112
  composition: OrderComposition;
113
+ variation?: PickerVariationChange;
109
114
  }) => void;
110
115
  /**
111
116
  * When editing an existing order, pass the order id here so the backend
@@ -87,6 +87,7 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
87
87
  });
88
88
  const onLoadingChangeRef = (0, react_1.useRef)(onLoadingChange);
89
89
  onLoadingChangeRef.current = onLoadingChange;
90
+ const pickedVariationRef = (0, react_1.useRef)(null);
90
91
  (0, react_1.useEffect)(() => {
91
92
  var _a;
92
93
  (_a = onLoadingChangeRef.current) === null || _a === void 0 ? void 0 : _a.call(onLoadingChangeRef, pickerLoading);
@@ -268,7 +269,8 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
268
269
  discountType: slotDiscountType,
269
270
  rounding: 'floor',
270
271
  });
271
- const personPrice = totalHeads > 0 ? Math.round(teamPrice / totalHeads / 100) * 100 : 0;
272
+ // До цента, кроме сумм от 1000 — там копейки только шумят.
273
+ const personPrice = totalHeads > 0 ? (0, format_amount_1.roundDisplayAmount)(teamPrice / totalHeads) : 0;
272
274
  /**
273
275
  * Пол выручки достигнут: следующая скидочная голова уже не снижает цену.
274
276
  * Считаем сравнением с составом без скидок — так работает для любого
@@ -312,6 +314,10 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
312
314
  const change = buildVariationChange(optionId);
313
315
  if (!change)
314
316
  return;
317
+ // Клик живёт только в этом монтировании пикера. Родитель не должен
318
+ // помнить его после закрытия попапа.
319
+ if (onVariationChange)
320
+ pickedVariationRef.current = change;
315
321
  onVariationChange === null || onVariationChange === void 0 ? void 0 : onVariationChange(change);
316
322
  };
317
323
  /**
@@ -441,6 +447,8 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
441
447
  defaultValue: 'Минимум {{count}} участников',
442
448
  }))),
443
449
  !!error && (react_1.default.createElement(toast_plate_1.ToastPlate, { view: 'negative' }, error || t('prices:playersError', { minPlayers, maxPlayers: pricingMax }))),
444
- !!onSave && (react_1.default.createElement(button_1.Button, { onClick: () => onSave(Object.assign(Object.assign({}, (0, composition_price_1.legacyCountsFromComposition)(_composition, categories)), { composition: _composition })), view: 'primary', size: 'xs', disabled: !bookable }, t('common:save')))));
450
+ !!onSave && (react_1.default.createElement(button_1.Button, { onClick: () => onSave(Object.assign(Object.assign(Object.assign({}, (0, composition_price_1.legacyCountsFromComposition)(_composition, categories)), { composition: _composition }), (pickedVariationRef.current
451
+ ? { variation: pickedVariationRef.current }
452
+ : {}))), view: 'primary', size: 'xs', disabled: !bookable }, t('common:save')))));
445
453
  };
446
454
  exports.PlayersAndVariationPicker = PlayersAndVariationPicker;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escapenavigator/hooks",
3
- "version": "2.1.21",
3
+ "version": "2.1.23",
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.21",
18
- "@escapenavigator/types": "^2.1.20",
19
- "@escapenavigator/utils": "^2.1.21",
17
+ "@escapenavigator/services": "^2.1.23",
18
+ "@escapenavigator/types": "^2.1.23",
19
+ "@escapenavigator/utils": "^2.1.23",
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": "f6f2c87403526437c511f5d3d844ba86e839dc88"
35
+ "gitHead": "533e344e432372b89b80d81c0ce35bc27463b677"
36
36
  }