@escapenavigator/hooks 2.0.45 → 2.0.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lead-form/index.d.ts +6 -0
- package/dist/lead-form/index.js +15 -0
- package/dist/lead-form/lead-field.d.ts +18 -0
- package/dist/lead-form/lead-field.js +107 -0
- package/dist/lead-form/lead-form.d.ts +46 -0
- package/dist/lead-form/lead-form.js +125 -0
- package/dist/lead-form/lib.d.ts +56 -0
- package/dist/lead-form/lib.js +170 -0
- package/dist/lead-form/use-lead-t.d.ts +2 -0
- package/dist/lead-form/use-lead-t.js +42 -0
- package/dist/players-and-variation-picker/index.d.ts +19 -0
- package/dist/players-and-variation-picker/index.js +80 -12
- package/dist/use-players-and-variation-picker/types.d.ts +12 -0
- package/package.json +5 -5
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from './consent-checkboxes';
|
|
|
2
2
|
export * from './functional-popover';
|
|
3
3
|
export * from './input-tel';
|
|
4
4
|
export * from './lazy-with-retry';
|
|
5
|
+
export * from './lead-form';
|
|
5
6
|
export * from './players-and-variation-picker';
|
|
6
7
|
export * from './use-debounced-value';
|
|
7
8
|
export * from './use-localstore-state';
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,7 @@ __exportStar(require("./consent-checkboxes"), exports);
|
|
|
18
18
|
__exportStar(require("./functional-popover"), exports);
|
|
19
19
|
__exportStar(require("./input-tel"), exports);
|
|
20
20
|
__exportStar(require("./lazy-with-retry"), exports);
|
|
21
|
+
__exportStar(require("./lead-form"), exports);
|
|
21
22
|
__exportStar(require("./players-and-variation-picker"), exports);
|
|
22
23
|
__exportStar(require("./use-debounced-value"), exports);
|
|
23
24
|
__exportStar(require("./use-localstore-state"), exports);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { LeadField } from './lead-field';
|
|
2
|
+
export type { LeadFormSubmit, LeadFormSubmitPayload, LeadFormSubmitResult } from './lead-form';
|
|
3
|
+
export { LeadForm } from './lead-form';
|
|
4
|
+
export type { LeadFieldValue, LeadFormPrefill, LeadFormValues } from './lib';
|
|
5
|
+
export { buildAnswers, buildInitialValues, isVisible, localized, validate } from './lib';
|
|
6
|
+
export { useLeadT } from './use-lead-t';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useLeadT = exports.validate = exports.localized = exports.isVisible = exports.buildInitialValues = exports.buildAnswers = exports.LeadForm = exports.LeadField = void 0;
|
|
4
|
+
var lead_field_1 = require("./lead-field");
|
|
5
|
+
Object.defineProperty(exports, "LeadField", { enumerable: true, get: function () { return lead_field_1.LeadField; } });
|
|
6
|
+
var lead_form_1 = require("./lead-form");
|
|
7
|
+
Object.defineProperty(exports, "LeadForm", { enumerable: true, get: function () { return lead_form_1.LeadForm; } });
|
|
8
|
+
var lib_1 = require("./lib");
|
|
9
|
+
Object.defineProperty(exports, "buildAnswers", { enumerable: true, get: function () { return lib_1.buildAnswers; } });
|
|
10
|
+
Object.defineProperty(exports, "buildInitialValues", { enumerable: true, get: function () { return lib_1.buildInitialValues; } });
|
|
11
|
+
Object.defineProperty(exports, "isVisible", { enumerable: true, get: function () { return lib_1.isVisible; } });
|
|
12
|
+
Object.defineProperty(exports, "localized", { enumerable: true, get: function () { return lib_1.localized; } });
|
|
13
|
+
Object.defineProperty(exports, "validate", { enumerable: true, get: function () { return lib_1.validate; } });
|
|
14
|
+
var use_lead_t_1 = require("./use-lead-t");
|
|
15
|
+
Object.defineProperty(exports, "useLeadT", { enumerable: true, get: function () { return use_lead_t_1.useLeadT; } });
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { LeadFormField } from '@escapenavigator/types/dist/lead/lead-form-field.type';
|
|
2
|
+
import { PublicLeadFormReferenceRO } from '@escapenavigator/types/dist/lead/public/public-lead-form.ro';
|
|
3
|
+
import { CountriesEnum } from '@escapenavigator/types/dist/shared/enum/countries.enum';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
import { LeadFieldValue } from './lib';
|
|
6
|
+
type Props = {
|
|
7
|
+
field: LeadFormField;
|
|
8
|
+
value: LeadFieldValue;
|
|
9
|
+
error?: string;
|
|
10
|
+
language: string;
|
|
11
|
+
profileCountry: CountriesEnum;
|
|
12
|
+
questrooms: PublicLeadFormReferenceRO[];
|
|
13
|
+
locations: PublicLeadFormReferenceRO[];
|
|
14
|
+
onChange: (value: LeadFieldValue) => void;
|
|
15
|
+
onPhoneValidChange: (valid: boolean) => void;
|
|
16
|
+
};
|
|
17
|
+
export declare const LeadField: React.FC<Props>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
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.LeadField = void 0;
|
|
8
|
+
const MinusM_1 = __importDefault(require("@alphakits/icons/dist/MinusM"));
|
|
9
|
+
const PlusM_1 = __importDefault(require("@alphakits/icons/dist/PlusM"));
|
|
10
|
+
const ui_1 = require("@alphakits/ui");
|
|
11
|
+
const lead_field_type_enum_1 = require("@escapenavigator/types/dist/lead/enums/lead-field-type.enum");
|
|
12
|
+
const react_1 = __importDefault(require("react"));
|
|
13
|
+
const input_tel_1 = require("../input-tel");
|
|
14
|
+
const index_module_css_1 = __importDefault(require("./index.module.css"));
|
|
15
|
+
const lib_1 = require("./lib");
|
|
16
|
+
const use_lead_t_1 = require("./use-lead-t");
|
|
17
|
+
const TIME_STEP_MINUTES = 30;
|
|
18
|
+
/** Слоты выбора времени: клиенту хватает получаса, а свободный ввод он ломает. */
|
|
19
|
+
const buildTimeOptions = () => {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (let minutes = 0; minutes < 24 * 60; minutes += TIME_STEP_MINUTES) {
|
|
22
|
+
const value = `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
|
|
23
|
+
out.push({ key: value, content: value });
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
};
|
|
27
|
+
const TIME_OPTIONS = buildTimeOptions();
|
|
28
|
+
const LeadField = ({ field, value, error, language, profileCountry, questrooms, locations, onChange, onPhoneValidChange, }) => {
|
|
29
|
+
var _a, _b;
|
|
30
|
+
const t = (0, use_lead_t_1.useLeadT)();
|
|
31
|
+
const title = (0, lib_1.fieldTitle)(field, language);
|
|
32
|
+
const label = field.required ? `${title} *` : title;
|
|
33
|
+
const placeholder = (0, lib_1.fieldText)(field, language, 'placeholder');
|
|
34
|
+
const description = (0, lib_1.fieldText)(field, language, 'description');
|
|
35
|
+
const hint = description ? (react_1.default.createElement(ui_1.Typography.Text, { view: 'primary-small', color: 'secondary' }, description)) : null;
|
|
36
|
+
const withHint = (control) => {
|
|
37
|
+
if (!hint)
|
|
38
|
+
return control;
|
|
39
|
+
return (react_1.default.createElement(ui_1.FlexColumns, { columns: 1, gr: 4 },
|
|
40
|
+
control,
|
|
41
|
+
hint));
|
|
42
|
+
};
|
|
43
|
+
const activeOptions = (field.options || []).filter((option) => option.active !== false);
|
|
44
|
+
const referenceOptions = (items) => items.map((item) => ({ key: String(item.id), content: item.title }));
|
|
45
|
+
switch (field.type) {
|
|
46
|
+
case lead_field_type_enum_1.LeadFieldTypeEnum.PHONE:
|
|
47
|
+
return withHint(react_1.default.createElement(input_tel_1.InputTel, { label: label, value: String(value || ''), profileCountry: profileCountry, locale: language, widgetMode: true, error: error, onChange: (payload) => {
|
|
48
|
+
onChange(payload.phone);
|
|
49
|
+
onPhoneValidChange(payload.isValid);
|
|
50
|
+
} }));
|
|
51
|
+
case lead_field_type_enum_1.LeadFieldTypeEnum.TEXTAREA:
|
|
52
|
+
return withHint(react_1.default.createElement(ui_1.Textarea, { block: true, maxLength: 1000, label: label, placeholder: placeholder, value: String(value || ''), error: error, onChange: (_e, payload) => onChange(payload.value) }));
|
|
53
|
+
case lead_field_type_enum_1.LeadFieldTypeEnum.DATE:
|
|
54
|
+
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
|
+
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) }));
|
|
59
|
+
case lead_field_type_enum_1.LeadFieldTypeEnum.GUESTS: {
|
|
60
|
+
const min = (_a = field.min) !== null && _a !== void 0 ? _a : 1;
|
|
61
|
+
const max = (_b = field.max) !== null && _b !== void 0 ? _b : 30;
|
|
62
|
+
const current = typeof value === 'number' ? value : min;
|
|
63
|
+
return (react_1.default.createElement(ui_1.FlexColumns, { columns: 1, gr: 8 },
|
|
64
|
+
react_1.default.createElement(ui_1.Typography.Text, { view: 'primary-small', weight: 'medium', color: 'primary' }, label),
|
|
65
|
+
react_1.default.createElement("div", { className: index_module_css_1.default.stepper },
|
|
66
|
+
react_1.default.createElement("button", { type: 'button', className: index_module_css_1.default.stepperButton, "aria-label": t('lead.guestsMinus'), disabled: current <= min, onClick: () => onChange(Math.max(min, current - 1)) },
|
|
67
|
+
react_1.default.createElement(MinusM_1.default, null)),
|
|
68
|
+
react_1.default.createElement("span", { className: index_module_css_1.default.stepperValue }, current),
|
|
69
|
+
react_1.default.createElement("button", { type: 'button', className: index_module_css_1.default.stepperButton, "aria-label": t('lead.guestsPlus'), disabled: current >= max, onClick: () => onChange(Math.min(max, current + 1)) },
|
|
70
|
+
react_1.default.createElement(PlusM_1.default, null))),
|
|
71
|
+
hint));
|
|
72
|
+
}
|
|
73
|
+
case lead_field_type_enum_1.LeadFieldTypeEnum.NUMBER:
|
|
74
|
+
return withHint(react_1.default.createElement(ui_1.Input, { block: true, label: label, placeholder: placeholder, inputMode: 'numeric', value: value === undefined ? '' : String(value), error: error, onChange: (_e, payload) => onChange(payload.value.replace(/\D/g, '') || undefined) }));
|
|
75
|
+
case lead_field_type_enum_1.LeadFieldTypeEnum.SELECT:
|
|
76
|
+
return withHint(react_1.default.createElement(ui_1.Select, { block: true, allowUnselect: !field.required, optionsListWidth: 'field', label: label, placeholder: placeholder, options: activeOptions.map((option) => ({
|
|
77
|
+
key: option.id,
|
|
78
|
+
content: (0, lib_1.optionLabel)(option, language, option.id),
|
|
79
|
+
})), selected: String(value || ''), error: error, onChange: ({ selected }) => onChange(selected === null || selected === void 0 ? void 0 : selected.key) }));
|
|
80
|
+
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))));
|
|
86
|
+
case lead_field_type_enum_1.LeadFieldTypeEnum.CHECKBOX: {
|
|
87
|
+
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))));
|
|
95
|
+
}
|
|
96
|
+
case lead_field_type_enum_1.LeadFieldTypeEnum.QUESTROOM:
|
|
97
|
+
case lead_field_type_enum_1.LeadFieldTypeEnum.LOCATION:
|
|
98
|
+
return withHint(react_1.default.createElement(ui_1.Select, { block: true, allowUnselect: !field.required, optionsListWidth: 'field', label: label, placeholder: placeholder, options: referenceOptions(field.type === lead_field_type_enum_1.LeadFieldTypeEnum.QUESTROOM ? questrooms : locations), selected: String(value || ''), error: error, onChange: ({ selected }) => onChange(selected === null || selected === void 0 ? void 0 : selected.key) }));
|
|
99
|
+
case lead_field_type_enum_1.LeadFieldTypeEnum.CONSENT:
|
|
100
|
+
return (react_1.default.createElement(ui_1.FlexColumns, { columns: 1, gr: 4 },
|
|
101
|
+
react_1.default.createElement(ui_1.Checkbox, { size: 's', align: 'start', block: true, label: react_1.default.createElement(ui_1.Typography.Text, { view: 'caps', color: 'secondary' }, title), hint: description, checked: value === true, onChange: (_e, payload) => onChange(!!(payload === null || payload === void 0 ? void 0 : payload.checked)) }),
|
|
102
|
+
!!error && (react_1.default.createElement(ui_1.Typography.Text, { view: 'caps', color: 'negative' }, error))));
|
|
103
|
+
default:
|
|
104
|
+
return withHint(react_1.default.createElement(ui_1.Input, { block: true, label: label, placeholder: placeholder, spellCheck: false, type: field.type === lead_field_type_enum_1.LeadFieldTypeEnum.EMAIL ? 'email' : 'text', value: String(value || ''), error: error, onChange: (_e, payload) => onChange(payload.value) }));
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
exports.LeadField = LeadField;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { LeadSourceEnum } from '@escapenavigator/types/dist/lead/enums/lead-source.enum';
|
|
2
|
+
import { PublicLeadFormRO } from '@escapenavigator/types/dist/lead/public/public-lead-form.ro';
|
|
3
|
+
import { SubmitLeadAnswerDto } from '@escapenavigator/types/dist/lead/public/submit-lead.dto';
|
|
4
|
+
import { SubmitLeadRO } from '@escapenavigator/types/dist/lead/public/submit-lead.ro';
|
|
5
|
+
import { CountriesEnum } from '@escapenavigator/types/dist/shared/enum/countries.enum';
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import { LeadFormPrefill } from './lib';
|
|
8
|
+
export type LeadFormSubmitPayload = {
|
|
9
|
+
formId: number;
|
|
10
|
+
answers: SubmitLeadAnswerDto[];
|
|
11
|
+
source: LeadSourceEnum;
|
|
12
|
+
trap?: string;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Отправку делает вызывающая сторона: из виджета заявка уходит с
|
|
16
|
+
* widget-токеном, со standalone-страницы — по uuid формы. `null` или
|
|
17
|
+
* ответ без `data` — обработанная ошибка. `error.message` с бэка нужна,
|
|
18
|
+
* чтобы показать лимит запросов отдельно от общего сбоя.
|
|
19
|
+
*/
|
|
20
|
+
export type LeadFormSubmitResult = {
|
|
21
|
+
data?: SubmitLeadRO;
|
|
22
|
+
error?: {
|
|
23
|
+
message?: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export type LeadFormSubmit = (payload: LeadFormSubmitPayload) => Promise<LeadFormSubmitResult | null>;
|
|
27
|
+
type Props = {
|
|
28
|
+
form: PublicLeadFormRO;
|
|
29
|
+
source: LeadSourceEnum;
|
|
30
|
+
language: string;
|
|
31
|
+
profileCountry: CountriesEnum;
|
|
32
|
+
submit: LeadFormSubmit;
|
|
33
|
+
/** Что клиент уже выбрал до формы — подставляем по роли поля. */
|
|
34
|
+
prefill?: LeadFormPrefill;
|
|
35
|
+
/** Контекст выбора (пакет, дата, гости) — уходит в комментарий заявки. */
|
|
36
|
+
contextNote?: string;
|
|
37
|
+
/** Шапку рисует контейнер, если у него своя (модалка, страница). */
|
|
38
|
+
withHeader?: boolean;
|
|
39
|
+
/** Что делать после успеха: модалка закрывается, страница остаётся. */
|
|
40
|
+
onDone?: () => void;
|
|
41
|
+
doneLabel?: string;
|
|
42
|
+
/** Контейнер прячет свою шапку, чтобы экран успеха не конкурировал с названием. */
|
|
43
|
+
onSuccess?: () => void;
|
|
44
|
+
};
|
|
45
|
+
export declare const LeadForm: React.FC<Props>;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* eslint-disable import/no-extraneous-dependencies */
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
|
|
37
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
38
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
39
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
40
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
41
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
42
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
43
|
+
});
|
|
44
|
+
};
|
|
45
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
46
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
47
|
+
};
|
|
48
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
49
|
+
exports.LeadForm = void 0;
|
|
50
|
+
const ui_1 = require("@alphakits/ui");
|
|
51
|
+
const react_1 = __importStar(require("react"));
|
|
52
|
+
const index_module_css_1 = __importDefault(require("./index.module.css"));
|
|
53
|
+
const lead_field_1 = require("./lead-field");
|
|
54
|
+
const lib_1 = require("./lib");
|
|
55
|
+
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 t = (0, use_lead_t_1.useLeadT)();
|
|
58
|
+
const [values, setValues] = (0, react_1.useState)(() => (0, lib_1.buildInitialValues)(form, prefill));
|
|
59
|
+
const [errors, setErrors] = (0, react_1.useState)({});
|
|
60
|
+
const [formError, setFormError] = (0, react_1.useState)();
|
|
61
|
+
const [phoneValid, setPhoneValid] = (0, react_1.useState)(false);
|
|
62
|
+
const [sending, setSending] = (0, react_1.useState)(false);
|
|
63
|
+
const [success, setSuccess] = (0, react_1.useState)();
|
|
64
|
+
// Honeypot: настоящий человек скрытое поле не увидит и не заполнит.
|
|
65
|
+
const [trap, setTrap] = (0, react_1.useState)('');
|
|
66
|
+
const setValue = (fieldId, value) => {
|
|
67
|
+
setValues((prev) => (Object.assign(Object.assign({}, prev), { [fieldId]: value })));
|
|
68
|
+
setErrors((prev) => (prev[fieldId] ? Object.assign(Object.assign({}, prev), { [fieldId]: '' }) : prev));
|
|
69
|
+
setFormError(undefined);
|
|
70
|
+
};
|
|
71
|
+
const handleSubmit = () => __awaiter(void 0, void 0, void 0, function* () {
|
|
72
|
+
var _a;
|
|
73
|
+
if (sending)
|
|
74
|
+
return;
|
|
75
|
+
const result = (0, lib_1.validate)({ form, values, phoneValid, t });
|
|
76
|
+
if (Object.keys(result.errors).length || result.formError) {
|
|
77
|
+
setErrors(result.errors);
|
|
78
|
+
setFormError(result.formError);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
setSending(true);
|
|
82
|
+
setFormError(undefined);
|
|
83
|
+
const response = yield submit({
|
|
84
|
+
formId: form.id,
|
|
85
|
+
answers: (0, lib_1.buildAnswers)(form, values, contextNote),
|
|
86
|
+
source,
|
|
87
|
+
trap: trap || undefined,
|
|
88
|
+
});
|
|
89
|
+
setSending(false);
|
|
90
|
+
if (!(response === null || response === void 0 ? void 0 : response.data)) {
|
|
91
|
+
setFormError(/too many/i.test(((_a = response === null || response === void 0 ? void 0 : response.error) === null || _a === void 0 ? void 0 : _a.message) || '')
|
|
92
|
+
? t('lead.tooManyRequests')
|
|
93
|
+
: t('lead.failed'));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
setSuccess(response.data);
|
|
97
|
+
onSuccess === null || onSuccess === void 0 ? void 0 : onSuccess();
|
|
98
|
+
});
|
|
99
|
+
if (success) {
|
|
100
|
+
return (react_1.default.createElement("div", { className: index_module_css_1.default.success, role: 'status' },
|
|
101
|
+
react_1.default.createElement("div", { className: index_module_css_1.default.successIcon, "aria-hidden": 'true' },
|
|
102
|
+
react_1.default.createElement("svg", { width: '28', height: '28', viewBox: '0 0 24 24', fill: 'none', "aria-hidden": 'true' },
|
|
103
|
+
react_1.default.createElement("path", { d: 'M5 12.5l4.2 4.2L19 7', stroke: 'currentColor', strokeWidth: '2.4', strokeLinecap: 'round', strokeLinejoin: 'round' }))),
|
|
104
|
+
react_1.default.createElement("div", { className: index_module_css_1.default.successCopy },
|
|
105
|
+
react_1.default.createElement(ui_1.Typography.Title, { tag: 'h1', view: 'small', weight: 'bold' }, (0, lib_1.localized)(success.successTitle, language) || t('lead.successTitle')),
|
|
106
|
+
react_1.default.createElement(ui_1.Typography.Text, { view: 'primary-medium', color: 'secondary' }, (0, lib_1.localized)(success.successDescription, language) || t('lead.successText'))),
|
|
107
|
+
!!onDone && (react_1.default.createElement("div", { className: index_module_css_1.default.successActions },
|
|
108
|
+
react_1.default.createElement(ui_1.Button, { view: 'primary', size: 's', block: true, onClick: onDone }, doneLabel || t('lead.close'))))));
|
|
109
|
+
}
|
|
110
|
+
const title = (0, lib_1.localized)(form.clientTitle, language) || t('lead.title');
|
|
111
|
+
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)))),
|
|
116
|
+
!!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 }))),
|
|
120
|
+
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'))));
|
|
124
|
+
};
|
|
125
|
+
exports.LeadForm = LeadForm;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { LeadFieldMappingEnum } from '@escapenavigator/types/dist/lead/enums/lead-field-mapping.enum';
|
|
2
|
+
import { LeadFieldTypeEnum } from '@escapenavigator/types/dist/lead/enums/lead-field-type.enum';
|
|
3
|
+
import { LeadFormField } from '@escapenavigator/types/dist/lead/lead-form-field.type';
|
|
4
|
+
import { PublicLeadFormRO } from '@escapenavigator/types/dist/lead/public/public-lead-form.ro';
|
|
5
|
+
import { SubmitLeadAnswerDto } from '@escapenavigator/types/dist/lead/public/submit-lead.dto';
|
|
6
|
+
import { LocalizedString } from '@escapenavigator/types/dist/shared/localized-string.type';
|
|
7
|
+
export type LeadFieldValue = string | number | boolean | string[] | undefined;
|
|
8
|
+
export type LeadFormValues = Record<string, LeadFieldValue>;
|
|
9
|
+
/**
|
|
10
|
+
* Текст на языке виджета. Площадка могла не перевести форму на все языки —
|
|
11
|
+
* откатываемся на английский, потом на любой заполненный: пустой заголовок
|
|
12
|
+
* хуже чужого языка.
|
|
13
|
+
*/
|
|
14
|
+
export declare const localized: (source: LocalizedString | undefined, language: string) => string;
|
|
15
|
+
export declare const fieldTitle: (field: LeadFormField, language: string) => string;
|
|
16
|
+
export declare const fieldText: (field: LeadFormField, language: string, key: "placeholder" | "description") => string;
|
|
17
|
+
export declare const optionLabel: (option: {
|
|
18
|
+
i18n: LocalizedString;
|
|
19
|
+
}, language: string, fallback: string) => string;
|
|
20
|
+
export declare const isMultiple: (type: LeadFieldTypeEnum) => boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Поле скрыто, пока в управляющем поле не выбран нужный вариант. Скрытое
|
|
23
|
+
* поле не валидируется и не попадает в ответы — иначе обязательная
|
|
24
|
+
* «уточните какой квест» блокировала бы отправку у всех остальных.
|
|
25
|
+
*/
|
|
26
|
+
export declare const isVisible: (field: LeadFormField, values: LeadFormValues) => boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Предзаполнение по смысловой роли поля, а не по его id: форма настраивается
|
|
29
|
+
* площадкой, и id полей у каждой свои. Роль (`mapsTo`) стабильна — по ней
|
|
30
|
+
* заявка из пакетов подставляет уже выбранную дату и число гостей.
|
|
31
|
+
*/
|
|
32
|
+
export type LeadFormPrefill = Partial<Record<LeadFieldMappingEnum, LeadFieldValue>>;
|
|
33
|
+
export declare const buildInitialValues: (form: PublicLeadFormRO, prefill?: LeadFormPrefill) => LeadFormValues;
|
|
34
|
+
type ValidateContext = {
|
|
35
|
+
form: PublicLeadFormRO;
|
|
36
|
+
values: LeadFormValues;
|
|
37
|
+
/** `InputTel` сам знает, валиден ли номер для выбранной страны. */
|
|
38
|
+
phoneValid: boolean;
|
|
39
|
+
t: (key: string) => string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Валидация на клиенте — это про удобство: сервер всё равно перепроверит
|
|
43
|
+
* обязательность и наличие канала связи. Возвращаем ошибки по id поля плюс
|
|
44
|
+
* общую ошибку формы.
|
|
45
|
+
*/
|
|
46
|
+
export declare const validate: ({ form, values, phoneValid, t, }: ValidateContext) => {
|
|
47
|
+
errors: Record<string, string>;
|
|
48
|
+
formError?: string;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* `contextNote` — то, что клиент уже выбрал до формы (пакет, дата, гости).
|
|
52
|
+
* Дописываем его в комментарий, а не в отдельное поле: у формы площадки
|
|
53
|
+
* такого поля может не быть, а менеджеру этот контекст нужен в карточке.
|
|
54
|
+
*/
|
|
55
|
+
export declare const buildAnswers: (form: PublicLeadFormRO, values: LeadFormValues, contextNote?: string) => SubmitLeadAnswerDto[];
|
|
56
|
+
export {};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildAnswers = exports.validate = exports.buildInitialValues = exports.isVisible = exports.isMultiple = exports.optionLabel = exports.fieldText = exports.fieldTitle = exports.localized = void 0;
|
|
4
|
+
const lead_field_mapping_enum_1 = require("@escapenavigator/types/dist/lead/enums/lead-field-mapping.enum");
|
|
5
|
+
const lead_field_type_enum_1 = require("@escapenavigator/types/dist/lead/enums/lead-field-type.enum");
|
|
6
|
+
const languages_enum_1 = require("@escapenavigator/types/dist/shared/enum/languages.enum");
|
|
7
|
+
/**
|
|
8
|
+
* Текст на языке виджета. Площадка могла не перевести форму на все языки —
|
|
9
|
+
* откатываемся на английский, потом на любой заполненный: пустой заголовок
|
|
10
|
+
* хуже чужого языка.
|
|
11
|
+
*/
|
|
12
|
+
const localized = (source, language) => {
|
|
13
|
+
if (!source)
|
|
14
|
+
return '';
|
|
15
|
+
const exact = source[language];
|
|
16
|
+
if (exact === null || exact === void 0 ? void 0 : exact.trim())
|
|
17
|
+
return exact;
|
|
18
|
+
const fallback = source[languages_enum_1.Languages.EN];
|
|
19
|
+
if (fallback === null || fallback === void 0 ? void 0 : fallback.trim())
|
|
20
|
+
return fallback;
|
|
21
|
+
return Object.values(source).find((value) => value === null || value === void 0 ? void 0 : value.trim()) || '';
|
|
22
|
+
};
|
|
23
|
+
exports.localized = localized;
|
|
24
|
+
const fieldTitle = (field, language) => {
|
|
25
|
+
var _a, _b, _c, _d;
|
|
26
|
+
const i18n = ((_a = field.i18n) === null || _a === void 0 ? void 0 : _a[language]) || ((_b = field.i18n) === null || _b === void 0 ? void 0 : _b[languages_enum_1.Languages.EN]);
|
|
27
|
+
if ((_c = i18n === null || i18n === void 0 ? void 0 : i18n.title) === null || _c === void 0 ? void 0 : _c.trim())
|
|
28
|
+
return i18n.title;
|
|
29
|
+
return ((_d = Object.values(field.i18n || {}).find((item) => { var _a; return (_a = item === null || item === void 0 ? void 0 : item.title) === null || _a === void 0 ? void 0 : _a.trim(); })) === null || _d === void 0 ? void 0 : _d.title) || '';
|
|
30
|
+
};
|
|
31
|
+
exports.fieldTitle = fieldTitle;
|
|
32
|
+
const fieldText = (field, language, key) => {
|
|
33
|
+
var _a, _b;
|
|
34
|
+
const i18n = ((_a = field.i18n) === null || _a === void 0 ? void 0 : _a[language]) || ((_b = field.i18n) === null || _b === void 0 ? void 0 : _b[languages_enum_1.Languages.EN]);
|
|
35
|
+
return (i18n === null || i18n === void 0 ? void 0 : i18n[key]) || '';
|
|
36
|
+
};
|
|
37
|
+
exports.fieldText = fieldText;
|
|
38
|
+
const optionLabel = (option, language, fallback) => (0, exports.localized)(option.i18n, language) || fallback;
|
|
39
|
+
exports.optionLabel = optionLabel;
|
|
40
|
+
const MULTI_TYPES = [lead_field_type_enum_1.LeadFieldTypeEnum.CHECKBOX];
|
|
41
|
+
const isMultiple = (type) => MULTI_TYPES.includes(type);
|
|
42
|
+
exports.isMultiple = isMultiple;
|
|
43
|
+
/**
|
|
44
|
+
* Поле скрыто, пока в управляющем поле не выбран нужный вариант. Скрытое
|
|
45
|
+
* поле не валидируется и не попадает в ответы — иначе обязательная
|
|
46
|
+
* «уточните какой квест» блокировала бы отправку у всех остальных.
|
|
47
|
+
*/
|
|
48
|
+
const isVisible = (field, values) => {
|
|
49
|
+
const condition = field.conditional;
|
|
50
|
+
if (!(condition === null || condition === void 0 ? void 0 : condition.fieldId))
|
|
51
|
+
return true;
|
|
52
|
+
const parentValue = values[condition.fieldId];
|
|
53
|
+
if (!condition.optionId) {
|
|
54
|
+
return Array.isArray(parentValue) ? parentValue.length > 0 : !!parentValue;
|
|
55
|
+
}
|
|
56
|
+
if (Array.isArray(parentValue))
|
|
57
|
+
return parentValue.includes(condition.optionId);
|
|
58
|
+
return parentValue === condition.optionId;
|
|
59
|
+
};
|
|
60
|
+
exports.isVisible = isVisible;
|
|
61
|
+
const buildInitialValues = (form, prefill) => {
|
|
62
|
+
var _a;
|
|
63
|
+
const values = {};
|
|
64
|
+
for (const field of form.fields) {
|
|
65
|
+
const prefilled = field.mapsTo ? prefill === null || prefill === void 0 ? void 0 : prefill[field.mapsTo] : undefined;
|
|
66
|
+
if (prefilled !== undefined && prefilled !== '') {
|
|
67
|
+
values[field.id] = prefilled;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (field.type === lead_field_type_enum_1.LeadFieldTypeEnum.CONSENT) {
|
|
71
|
+
values[field.id] = false;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if ((0, exports.isMultiple)(field.type)) {
|
|
75
|
+
values[field.id] = [];
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
// У степпера гостей всегда есть значение: пустое поле «сколько вас»
|
|
79
|
+
// клиент чаще пропускает, чем заполняет.
|
|
80
|
+
if (field.type === lead_field_type_enum_1.LeadFieldTypeEnum.GUESTS) {
|
|
81
|
+
values[field.id] = (_a = field.min) !== null && _a !== void 0 ? _a : 2;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
values[field.id] = '';
|
|
85
|
+
}
|
|
86
|
+
return values;
|
|
87
|
+
};
|
|
88
|
+
exports.buildInitialValues = buildInitialValues;
|
|
89
|
+
const isEmpty = (value) => {
|
|
90
|
+
if (Array.isArray(value))
|
|
91
|
+
return !value.length;
|
|
92
|
+
if (typeof value === 'boolean')
|
|
93
|
+
return !value;
|
|
94
|
+
if (typeof value === 'number')
|
|
95
|
+
return false;
|
|
96
|
+
return !(value === null || value === void 0 ? void 0 : value.trim());
|
|
97
|
+
};
|
|
98
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
|
99
|
+
/**
|
|
100
|
+
* Валидация на клиенте — это про удобство: сервер всё равно перепроверит
|
|
101
|
+
* обязательность и наличие канала связи. Возвращаем ошибки по id поля плюс
|
|
102
|
+
* общую ошибку формы.
|
|
103
|
+
*/
|
|
104
|
+
const validate = ({ form, values, phoneValid, t, }) => {
|
|
105
|
+
const errors = {};
|
|
106
|
+
let hasPhone = false;
|
|
107
|
+
let hasEmail = false;
|
|
108
|
+
for (const field of form.fields) {
|
|
109
|
+
if (!(0, exports.isVisible)(field, values))
|
|
110
|
+
continue;
|
|
111
|
+
const value = values[field.id];
|
|
112
|
+
const empty = isEmpty(value);
|
|
113
|
+
if (field.required && empty) {
|
|
114
|
+
errors[field.id] = t('lead.required');
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (empty)
|
|
118
|
+
continue;
|
|
119
|
+
if (field.type === lead_field_type_enum_1.LeadFieldTypeEnum.PHONE) {
|
|
120
|
+
if (!phoneValid) {
|
|
121
|
+
errors[field.id] = t('lead.invalidPhone');
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
hasPhone = true;
|
|
125
|
+
}
|
|
126
|
+
if (field.type === lead_field_type_enum_1.LeadFieldTypeEnum.EMAIL) {
|
|
127
|
+
if (!EMAIL_RE.test(String(value).trim())) {
|
|
128
|
+
errors[field.id] = t('lead.invalidEmail');
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
hasEmail = true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Заявку без телефона и почты обработать нельзя — бэкенд её отклонит,
|
|
135
|
+
// так что честнее сказать это до отправки.
|
|
136
|
+
const formError = !hasPhone && !hasEmail && !Object.keys(errors).length ? t('lead.needContact') : undefined;
|
|
137
|
+
return { errors, formError };
|
|
138
|
+
};
|
|
139
|
+
exports.validate = validate;
|
|
140
|
+
/**
|
|
141
|
+
* `contextNote` — то, что клиент уже выбрал до формы (пакет, дата, гости).
|
|
142
|
+
* Дописываем его в комментарий, а не в отдельное поле: у формы площадки
|
|
143
|
+
* такого поля может не быть, а менеджеру этот контекст нужен в карточке.
|
|
144
|
+
*/
|
|
145
|
+
const buildAnswers = (form, values, contextNote) => {
|
|
146
|
+
const commentField = form.fields.find((field) => field.mapsTo === lead_field_mapping_enum_1.LeadFieldMappingEnum.COMMENT && (0, exports.isVisible)(field, values));
|
|
147
|
+
return form.fields
|
|
148
|
+
.filter((field) => (0, exports.isVisible)(field, values))
|
|
149
|
+
.map((field) => {
|
|
150
|
+
const value = normalizeValue(values[field.id]);
|
|
151
|
+
if (!contextNote || field.id !== (commentField === null || commentField === void 0 ? void 0 : commentField.id)) {
|
|
152
|
+
return { fieldId: field.id, value };
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
fieldId: field.id,
|
|
156
|
+
value: value ? `${contextNote}\n${value}` : contextNote,
|
|
157
|
+
};
|
|
158
|
+
})
|
|
159
|
+
.filter((answer) => answer.value !== undefined);
|
|
160
|
+
};
|
|
161
|
+
exports.buildAnswers = buildAnswers;
|
|
162
|
+
const normalizeValue = (value) => {
|
|
163
|
+
if (value === undefined || value === null)
|
|
164
|
+
return undefined;
|
|
165
|
+
if (Array.isArray(value))
|
|
166
|
+
return value.length ? value : undefined;
|
|
167
|
+
if (typeof value === 'string')
|
|
168
|
+
return value.trim() || undefined;
|
|
169
|
+
return value;
|
|
170
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.useLeadT = void 0;
|
|
4
|
+
const react_1 = require("react");
|
|
5
|
+
const react_i18next_1 = require("react-i18next");
|
|
6
|
+
/**
|
|
7
|
+
* Тексты формы заявки: часть приходит из настроек площадки (заголовок,
|
|
8
|
+
* подписи полей), но служебные строки — наши. Пока ключи не заведены в
|
|
9
|
+
* translations-сервисе, держим английские дефолты рядом с UI — ровно как
|
|
10
|
+
* в витрине пакетов (`use-package-t`).
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULTS = {
|
|
13
|
+
'lead.title': 'Leave a request',
|
|
14
|
+
'lead.hint': 'Tell us what you need and we will call you back.',
|
|
15
|
+
'lead.submit': 'Send',
|
|
16
|
+
'lead.sending': 'Sending…',
|
|
17
|
+
'lead.successTitle': 'Thank you!',
|
|
18
|
+
'lead.successText': 'We have got your request and will contact you shortly.',
|
|
19
|
+
'lead.required': 'Please fill in this field',
|
|
20
|
+
'lead.invalidPhone': 'Enter a valid phone number',
|
|
21
|
+
'lead.invalidEmail': 'Enter a valid email',
|
|
22
|
+
'lead.needContact': 'Leave a phone number or an email so we can reach you',
|
|
23
|
+
'lead.failed': 'Failed to send the request, please try again',
|
|
24
|
+
'lead.tooManyRequests': 'Too many requests. Please try again later.',
|
|
25
|
+
'lead.unavailable': 'The request form is not available right now',
|
|
26
|
+
'lead.guestsMinus': 'One guest less',
|
|
27
|
+
'lead.guestsPlus': 'One guest more',
|
|
28
|
+
'lead.close': 'Close',
|
|
29
|
+
'lead.another': 'Send another request',
|
|
30
|
+
// Кнопка «оставить заявку» рядом с расписанием.
|
|
31
|
+
'lead.ctaTitle': 'Cannot find a suitable time?',
|
|
32
|
+
'lead.ctaHint': 'Leave a request and we will arrange the booking for you',
|
|
33
|
+
'lead.ctaButton': 'Leave a request',
|
|
34
|
+
// Та же кнопка в витрине сертификатов: про время говорить нельзя.
|
|
35
|
+
'lead.certCtaTitle': 'Need help with a gift?',
|
|
36
|
+
'lead.certCtaHint': 'Leave a request and we will pick a certificate for you',
|
|
37
|
+
};
|
|
38
|
+
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]);
|
|
41
|
+
};
|
|
42
|
+
exports.useLeadT = useLeadT;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ProfileCurrencyEnum } from '@escapenavigator/types/dist/profile/enum/profile-currency';
|
|
2
2
|
import { SlotDiscountTypeEnum } from '@escapenavigator/types/dist/slot/enum/slot-discount-type.enum';
|
|
3
|
+
import { TariffReducedRateModeEnum } from '@escapenavigator/types/dist/tariff/enum/tariff-reduced-rate-mode.enum';
|
|
3
4
|
import React from 'react';
|
|
4
5
|
import { usePlayersAndVariationPicker } from '../use-players-and-variation-picker';
|
|
5
6
|
import { PickerSlotDetails, PickerSlotInfo, PickerSlotVariation, PickerVariationChange } from '../use-players-and-variation-picker/types';
|
|
@@ -39,6 +40,23 @@ export type PlayersAndVariationPickerProps = {
|
|
|
39
40
|
players: number;
|
|
40
41
|
/** Children form field value (defaults to 0). */
|
|
41
42
|
child?: number;
|
|
43
|
+
/** Сколько мест выбрано по льготной ставке (школьники и т.п.). */
|
|
44
|
+
reducedPlayers?: number;
|
|
45
|
+
/**
|
|
46
|
+
* Льготная ставка и минимальная стоимость игры. По умолчанию берутся из
|
|
47
|
+
* выбранной вариации (`/slots/details`). Явно передают там, где условия
|
|
48
|
+
* нужно взять со снимка на заказе, а не с текущего тарифа — карточка уже
|
|
49
|
+
* оформленной брони.
|
|
50
|
+
*/
|
|
51
|
+
reduced?: {
|
|
52
|
+
minGameAmount?: number | null;
|
|
53
|
+
reducedRateMode?: TariffReducedRateModeEnum | null;
|
|
54
|
+
reducedRate?: number | null;
|
|
55
|
+
reducedTitle?: string | null;
|
|
56
|
+
reducedPriceDescription?: string | null;
|
|
57
|
+
};
|
|
58
|
+
/** Fires when the guest changes the number of reduced seats. */
|
|
59
|
+
onReducedChange?: (value: number) => void;
|
|
42
60
|
/**
|
|
43
61
|
* Override for the price map used to render player buttons. Defaults to
|
|
44
62
|
* the selected variation's tariff; pass an explicit value when the host
|
|
@@ -78,6 +96,7 @@ export type PlayersAndVariationPickerProps = {
|
|
|
78
96
|
onSave?: (data: {
|
|
79
97
|
players: number;
|
|
80
98
|
children: number;
|
|
99
|
+
reducedPlayers: number;
|
|
81
100
|
}) => void;
|
|
82
101
|
/**
|
|
83
102
|
* When editing an existing order, pass the order id here so the backend
|
|
@@ -52,6 +52,7 @@ const format_amount_1 = require("@escapenavigator/utils/dist/format-amount");
|
|
|
52
52
|
const get_players_price_1 = require("@escapenavigator/utils/dist/get-players-price");
|
|
53
53
|
const get_prices_1 = require("@escapenavigator/utils/dist/get-prices");
|
|
54
54
|
const get_slot_discount_amount_1 = require("@escapenavigator/utils/dist/get-slot-discount-amount");
|
|
55
|
+
const reduced_price_1 = require("@escapenavigator/utils/dist/reduced-price");
|
|
55
56
|
const react_1 = __importStar(require("react"));
|
|
56
57
|
const react_i18next_1 = require("react-i18next");
|
|
57
58
|
const use_players_and_variation_picker_1 = require("../use-players-and-variation-picker");
|
|
@@ -64,8 +65,8 @@ const use_players_and_variation_picker_1 = require("../use-players-and-variation
|
|
|
64
65
|
* buttons, kids row, price hint, save button) was reimplemented in three
|
|
65
66
|
* places with subtle drift. This component is the single source of truth.
|
|
66
67
|
*/
|
|
67
|
-
const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loadSlotDetails, onVariationChange, onVariationResolved, onDetailsLoaded, players = 0, child = 0, tariff: tariffOverride, currency, minPlayers, maxPlayersLimit, slotDiscount, slotDiscountType, error, t, onChange, onSave, orderId, onLoadingChange, hidePriceHints = false, flexPlayersOption, }) => {
|
|
68
|
-
var _a, _b;
|
|
68
|
+
const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loadSlotDetails, onVariationChange, onVariationResolved, onDetailsLoaded, players = 0, child = 0, reducedPlayers = 0, reduced: reducedOverride, onReducedChange, tariff: tariffOverride, currency, minPlayers, maxPlayersLimit, slotDiscount, slotDiscountType, error, t, onChange, onSave, orderId, onLoadingChange, hidePriceHints = false, flexPlayersOption, }) => {
|
|
69
|
+
var _a, _b, _c, _d, _e, _f;
|
|
69
70
|
/**
|
|
70
71
|
* Страховка от «голых ключей» перевода. Пикер рендерит `prices:*` /
|
|
71
72
|
* `common:*` через переданный хостом `t`, но сам хост часто берёт
|
|
@@ -132,12 +133,16 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
|
|
|
132
133
|
]);
|
|
133
134
|
const [_players, setPlayers] = (0, react_1.useState)(players);
|
|
134
135
|
const [_children, setChildren] = (0, react_1.useState)(child);
|
|
136
|
+
const [_reduced, setReduced] = (0, react_1.useState)(reducedPlayers);
|
|
135
137
|
(0, react_1.useEffect)(() => {
|
|
136
138
|
setPlayers(players);
|
|
137
139
|
}, [players]);
|
|
138
140
|
(0, react_1.useEffect)(() => {
|
|
139
141
|
setChildren(child);
|
|
140
142
|
}, [child]);
|
|
143
|
+
(0, react_1.useEffect)(() => {
|
|
144
|
+
setReduced(reducedPlayers);
|
|
145
|
+
}, [reducedPlayers]);
|
|
141
146
|
const flexSelected = !!(flexPlayersOption === null || flexPlayersOption === void 0 ? void 0 : flexPlayersOption.selected);
|
|
142
147
|
const handleChangePlayers = (value) => {
|
|
143
148
|
setPlayers(value);
|
|
@@ -215,11 +220,46 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
|
|
|
215
220
|
const maxChildren = maxFromTariff + 1;
|
|
216
221
|
return Array.from({ length: maxChildren }, (_, i) => i);
|
|
217
222
|
}, [tariff, maxFromTariff]);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
+
// Условия льготной ставки: снимок с брони (если хост его передал) важнее
|
|
224
|
+
// текущего тарифа — иначе повышение цен переоценило бы открытую карточку.
|
|
225
|
+
const reducedConfig = (0, react_1.useMemo)(() => {
|
|
226
|
+
var _a, _b, _c, _d, _e, _f;
|
|
227
|
+
return ({
|
|
228
|
+
minGameAmount: (_b = (_a = reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.minGameAmount) !== null && _a !== void 0 ? _a : selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.minGameAmount) !== null && _b !== void 0 ? _b : 0,
|
|
229
|
+
reducedRateMode: (_d = (_c = reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedRateMode) !== null && _c !== void 0 ? _c : selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedRateMode) !== null && _d !== void 0 ? _d : null,
|
|
230
|
+
reducedRate: (_f = (_e = reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedRate) !== null && _e !== void 0 ? _e : selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedRate) !== null && _f !== void 0 ? _f : 0,
|
|
231
|
+
});
|
|
232
|
+
}, [
|
|
233
|
+
reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.minGameAmount,
|
|
234
|
+
reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedRateMode,
|
|
235
|
+
reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedRate,
|
|
236
|
+
selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.minGameAmount,
|
|
237
|
+
selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedRateMode,
|
|
238
|
+
selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedRate,
|
|
239
|
+
]);
|
|
240
|
+
const reducedTitle = (_b = (_a = reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedTitle) !== null && _a !== void 0 ? _a : selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedTitle) !== null && _b !== void 0 ? _b : '';
|
|
241
|
+
const reducedDescription = ((_d = (_c = reducedOverride === null || reducedOverride === void 0 ? void 0 : reducedOverride.reducedPriceDescription) !== null && _c !== void 0 ? _c : selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.reducedPriceDescription) !== null && _d !== void 0 ? _d : '').trim();
|
|
242
|
+
const hasReducedRate = !!reducedConfig.reducedRateMode && !!reducedConfig.reducedRate;
|
|
243
|
+
// Лимит льготных мест не приходит с бэка числом: он выводится из
|
|
244
|
+
// минимальной стоимости игры той же формулой, которой бэк посчитает цену.
|
|
245
|
+
const maxReduced = (0, react_1.useMemo)(() => hasReducedRate
|
|
246
|
+
? (0, reduced_price_1.getMaxReducedPlayers)(Object.assign({ price: tariff, players: _players }, reducedConfig))
|
|
247
|
+
: 0, [hasReducedRate, tariff, _players, reducedConfig]);
|
|
248
|
+
const reducedCounts = (0, react_1.useMemo)(() => Array.from({ length: maxReduced + 1 }, (_, index) => index), [maxReduced]);
|
|
249
|
+
const handleChangeReduced = (value) => {
|
|
250
|
+
setReduced(value);
|
|
251
|
+
onReducedChange === null || onReducedChange === void 0 ? void 0 : onReducedChange(value);
|
|
252
|
+
};
|
|
253
|
+
// Состав уменьшился (или сменилась вариация) — вчерашние 3 льготных места
|
|
254
|
+
// могут больше не влезать в минимум. Подтягиваем вниз, иначе бэк отдал бы
|
|
255
|
+
// `reducedPlayersLimitExceeded` уже на сохранении.
|
|
256
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: односторонний clamp
|
|
257
|
+
(0, react_1.useEffect)(() => {
|
|
258
|
+
if (_reduced > maxReduced)
|
|
259
|
+
handleChangeReduced(maxReduced);
|
|
260
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
261
|
+
}, [maxReduced, _reduced]);
|
|
262
|
+
const teamPriceBase = (0, get_players_price_1.getPlayersPrice)(Object.assign({ players: _players, children: _children, price: tariff, reducedPlayers: Math.min(_reduced, maxReduced) }, reducedConfig));
|
|
223
263
|
const teamPrice = teamPriceBase -
|
|
224
264
|
(0, get_slot_discount_amount_1.getSlotDiscountAmount)({
|
|
225
265
|
price: teamPriceBase,
|
|
@@ -231,13 +271,13 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
|
|
|
231
271
|
const personPrice = totalHeads > 0 ? Math.round(teamPrice / totalHeads / 100) * 100 : 0;
|
|
232
272
|
// Описание цены за детей показываем только когда дети реально выбраны.
|
|
233
273
|
const childDescription = _children
|
|
234
|
-
? (
|
|
274
|
+
? (_e = selectedVariation === null || selectedVariation === void 0 ? void 0 : selectedVariation.childPriceDescription) === null || _e === void 0 ? void 0 : _e.trim()
|
|
235
275
|
: undefined;
|
|
236
276
|
const showVariations = pickerOptions.length > 1;
|
|
237
277
|
// Клиентское описание показываем только для вариаций — у стандартного
|
|
238
278
|
// (default) тарифа описание не выводим.
|
|
239
279
|
const variationDescription = selectedVariation && !selectedVariation.isDefault
|
|
240
|
-
? ((
|
|
280
|
+
? ((_f = selectedVariation.description) !== null && _f !== void 0 ? _f : null)
|
|
241
281
|
: null;
|
|
242
282
|
const handleVariationClick = (optionId, isFree) => {
|
|
243
283
|
if (!isFree)
|
|
@@ -301,13 +341,35 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
|
|
|
301
341
|
const isActive = _children === count;
|
|
302
342
|
return (react_1.default.createElement(tag_1.Tag, { key: `child-${count}`, text: count === 0 ? t('prices:noKids') : `${count}`, onClick: () => handleChangeChildren(isActive ? 0 : count), view: isActive ? 'primary-inverted' : 'primary' }));
|
|
303
343
|
})))),
|
|
344
|
+
!flexSelected && hasReducedRate && !!_players && (react_1.default.createElement(flex_columns_1.FlexColumns, { columns: 1, gr: 8 },
|
|
345
|
+
react_1.default.createElement(flex_1.Flex, { gap: 'sm', align: 'center', justify: 'between' },
|
|
346
|
+
react_1.default.createElement(typography_1.Typography.Text, { view: 'title', color: 'secondary' }, reducedTitle ||
|
|
347
|
+
t('prices:reducedPlayers', { defaultValue: 'Льготные места' })),
|
|
348
|
+
!!_reduced && (react_1.default.createElement(typography_1.Typography.Text, { view: 'title', color: 'secondary' }, `− ${(0, format_amount_1.formatAmount)(Math.min(_reduced, maxReduced) *
|
|
349
|
+
(0, reduced_price_1.getReducedSeatDiscount)(Object.assign({ price: tariff, players: _players }, reducedConfig)), currency)}`))),
|
|
350
|
+
maxReduced > 0 ? (react_1.default.createElement(flex_1.Flex, { gap: 'xs', wrap: true, justify: 'start' }, reducedCounts.map((count) => {
|
|
351
|
+
const isActive = Math.min(_reduced, maxReduced) === count;
|
|
352
|
+
return (react_1.default.createElement(tag_1.Tag, { key: `reduced-${count}`, text: count === 0
|
|
353
|
+
? t('prices:noReducedPlayers', {
|
|
354
|
+
defaultValue: 'Нет',
|
|
355
|
+
})
|
|
356
|
+
: `${count}`, onClick: () => handleChangeReduced(isActive ? 0 : count), view: isActive ? 'primary-inverted' : 'primary' }));
|
|
357
|
+
}))) : (react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, t('prices:reducedNotAvailable', {
|
|
358
|
+
players: _players,
|
|
359
|
+
minAmount: (0, format_amount_1.formatAmount)(reducedConfig.minGameAmount || 0, currency),
|
|
360
|
+
defaultValue: 'На такой состав льготных мест нет: игра не может стоить меньше {{minAmount}}.',
|
|
361
|
+
}))),
|
|
362
|
+
!hidePriceHints && !!reducedDescription && !!_reduced && (react_1.default.createElement("div", { style: { whiteSpace: 'pre-line' } },
|
|
363
|
+
react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, reducedDescription))))),
|
|
304
364
|
!hidePriceHints && !flexSelected && !!totalHeads && (react_1.default.createElement(flex_1.Flex, { justify: 'start', gap: 'xs' },
|
|
305
365
|
react_1.default.createElement(InfoMarkS_1.default, { style: { fill: 'var(--color-text-secondary)' } }),
|
|
306
366
|
react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, t('prices:totalPriceDescription', {
|
|
307
367
|
teamPrice: (0, format_amount_1.formatAmount)(teamPrice, currency),
|
|
308
368
|
personPrice: (0, format_amount_1.formatAmount)(personPrice, currency),
|
|
309
369
|
})))),
|
|
310
|
-
hidePriceHints &&
|
|
370
|
+
hidePriceHints &&
|
|
371
|
+
!flexSelected &&
|
|
372
|
+
(!!totalHeads || !!childDescription || (!!_reduced && !!reducedDescription)) && (react_1.default.createElement("div", { style: {
|
|
311
373
|
display: 'flex',
|
|
312
374
|
flexDirection: 'column',
|
|
313
375
|
gap: 6,
|
|
@@ -323,8 +385,14 @@ const PlayersAndVariationPicker = ({ slot, currentTariffId, currentDuration, loa
|
|
|
323
385
|
personPrice: (0, format_amount_1.formatAmount)(personPrice, currency),
|
|
324
386
|
})))),
|
|
325
387
|
!!childDescription && (react_1.default.createElement("div", { style: { whiteSpace: 'pre-line' } },
|
|
326
|
-
react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, childDescription)))
|
|
388
|
+
react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, childDescription))),
|
|
389
|
+
!!_reduced && !!reducedDescription && (react_1.default.createElement("div", { style: { whiteSpace: 'pre-line' } },
|
|
390
|
+
react_1.default.createElement(typography_1.Typography.Text, { view: 'caps', color: 'secondary' }, reducedDescription))))),
|
|
327
391
|
!!error && (react_1.default.createElement(toast_plate_1.ToastPlate, { view: 'negative' }, error || t('prices:playersError', { minPlayers, maxPlayers: effectiveMax }))),
|
|
328
|
-
!!onSave && (react_1.default.createElement(button_1.Button, { onClick: () => onSave({
|
|
392
|
+
!!onSave && (react_1.default.createElement(button_1.Button, { onClick: () => onSave({
|
|
393
|
+
players: _players,
|
|
394
|
+
children: _children,
|
|
395
|
+
reducedPlayers: Math.min(_reduced, maxReduced),
|
|
396
|
+
}), view: 'primary', size: 'xs' }, t('common:save')))));
|
|
329
397
|
};
|
|
330
398
|
exports.PlayersAndVariationPicker = PlayersAndVariationPicker;
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* (`OpenapiSlotRO`/`OpenapiSlotEnrichedRO`) contracts so a single picker can
|
|
6
6
|
* be plugged into either context.
|
|
7
7
|
*/
|
|
8
|
+
import { TariffReducedRateModeEnum } from '@escapenavigator/types/dist/tariff/enum/tariff-reduced-rate-mode.enum';
|
|
8
9
|
export type PickerSlotVariation = {
|
|
9
10
|
/**
|
|
10
11
|
* Stable picker key returned by the backend (`${tariffId}-${duration}`).
|
|
@@ -23,6 +24,17 @@ export type PickerSlotVariation = {
|
|
|
23
24
|
[k: string]: number;
|
|
24
25
|
};
|
|
25
26
|
childPriceDescription?: string | null;
|
|
27
|
+
/**
|
|
28
|
+
* Минимальная стоимость игры и льготная ставка тарифа. Лимит льготных мест
|
|
29
|
+
* пикер считает сам (`getMaxReducedPlayers`) — той же формулой, которой бэк
|
|
30
|
+
* посчитает цену, поэтому «сколько мест влезает» не может разойтись между
|
|
31
|
+
* UI и броней.
|
|
32
|
+
*/
|
|
33
|
+
minGameAmount?: number;
|
|
34
|
+
reducedRateMode?: TariffReducedRateModeEnum | null;
|
|
35
|
+
reducedRate?: number;
|
|
36
|
+
reducedTitle?: string | null;
|
|
37
|
+
reducedPriceDescription?: string | null;
|
|
26
38
|
minPlayers: number;
|
|
27
39
|
maxPlayers: number;
|
|
28
40
|
numSeatsAvailable: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escapenavigator/hooks",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.47",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"test": "jest"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@escapenavigator/services": "^2.0.
|
|
18
|
-
"@escapenavigator/types": "^2.0.
|
|
19
|
-
"@escapenavigator/utils": "^2.0.
|
|
17
|
+
"@escapenavigator/services": "^2.0.47",
|
|
18
|
+
"@escapenavigator/types": "^2.0.45",
|
|
19
|
+
"@escapenavigator/utils": "^2.0.47",
|
|
20
20
|
"libphonenumber-js": "^1.12.24",
|
|
21
21
|
"react": "19.2.6",
|
|
22
22
|
"react-phone-input-2": "^2.15.1"
|
|
@@ -32,5 +32,5 @@
|
|
|
32
32
|
"ts-jest": "^29.1.1",
|
|
33
33
|
"typescript": "^5.6"
|
|
34
34
|
},
|
|
35
|
-
"gitHead": "
|
|
35
|
+
"gitHead": "cd6ca16767c8a03b4deded5c6abc8943b02ce1ee"
|
|
36
36
|
}
|