@escapenavigator/utils 1.10.132 → 1.10.134

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.
@@ -0,0 +1,31 @@
1
+ export declare const BOOKING_LEAD_TIME_MINUTE_STEP = 5;
2
+ /** Максимум в селекте «часы» (7 суток). */
3
+ export declare const BOOKING_LEAD_TIME_MAX_HOURS = 168;
4
+ export declare const BOOKING_LEAD_TIME_MAX_MINUTES: number;
5
+ export type BookingLeadTimeParts = {
6
+ hours: number;
7
+ minutes: number;
8
+ };
9
+ export declare const roundBookingLeadMinutesToStep: (totalMinutes: number, step?: number) => number;
10
+ export declare const minutesToBookingLeadParts: (totalMinutes: number) => BookingLeadTimeParts;
11
+ export declare const bookingLeadPartsToMinutes: (hours: number, minutes: number) => number;
12
+ export declare const buildBookingLeadHourOptions: () => Array<{
13
+ key: number;
14
+ content: number;
15
+ }>;
16
+ export declare const buildBookingLeadMinuteOptions: () => Array<{
17
+ key: number;
18
+ content: number;
19
+ }>;
20
+ type RuleLeadTimeLegacy = {
21
+ blockingMinutes?: number | null;
22
+ blockingHours?: number | null;
23
+ minMinutesForBooking?: number | null;
24
+ minHoursForBooking?: number | null;
25
+ };
26
+ export declare const resolveBlockingMinutes: (rule?: RuleLeadTimeLegacy | null) => number;
27
+ export declare const resolveMinMinutesForBooking: (rule?: RuleLeadTimeLegacy | null) => number;
28
+ type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
29
+ /** Человекочитаемый интервал до начала игры для таблиц и описаний. */
30
+ export declare const formatBookingLeadTime: (totalMinutes: number, t: TranslateFn) => string;
31
+ export {};
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatBookingLeadTime = exports.resolveMinMinutesForBooking = exports.resolveBlockingMinutes = exports.buildBookingLeadMinuteOptions = exports.buildBookingLeadHourOptions = exports.bookingLeadPartsToMinutes = exports.minutesToBookingLeadParts = exports.roundBookingLeadMinutesToStep = exports.BOOKING_LEAD_TIME_MAX_MINUTES = exports.BOOKING_LEAD_TIME_MAX_HOURS = exports.BOOKING_LEAD_TIME_MINUTE_STEP = void 0;
4
+ exports.BOOKING_LEAD_TIME_MINUTE_STEP = 5;
5
+ /** Максимум в селекте «часы» (7 суток). */
6
+ exports.BOOKING_LEAD_TIME_MAX_HOURS = 168;
7
+ exports.BOOKING_LEAD_TIME_MAX_MINUTES = exports.BOOKING_LEAD_TIME_MAX_HOURS * 60 + 55;
8
+ const roundBookingLeadMinutesToStep = (totalMinutes, step = exports.BOOKING_LEAD_TIME_MINUTE_STEP) => Math.max(0, Math.round(totalMinutes / step) * step);
9
+ exports.roundBookingLeadMinutesToStep = roundBookingLeadMinutesToStep;
10
+ const minutesToBookingLeadParts = (totalMinutes) => {
11
+ const normalized = (0, exports.roundBookingLeadMinutesToStep)(Math.max(0, totalMinutes || 0));
12
+ const hours = Math.min(exports.BOOKING_LEAD_TIME_MAX_HOURS, Math.floor(normalized / 60));
13
+ const minutes = Math.min(55, normalized - hours * 60);
14
+ return { hours, minutes };
15
+ };
16
+ exports.minutesToBookingLeadParts = minutesToBookingLeadParts;
17
+ const bookingLeadPartsToMinutes = (hours, minutes) => Math.min(exports.BOOKING_LEAD_TIME_MAX_MINUTES, Math.max(0, hours) * 60 + (0, exports.roundBookingLeadMinutesToStep)(minutes));
18
+ exports.bookingLeadPartsToMinutes = bookingLeadPartsToMinutes;
19
+ const buildBookingLeadHourOptions = () => Array.from({ length: exports.BOOKING_LEAD_TIME_MAX_HOURS + 1 }, (_, hours) => ({
20
+ key: hours,
21
+ content: hours,
22
+ }));
23
+ exports.buildBookingLeadHourOptions = buildBookingLeadHourOptions;
24
+ const buildBookingLeadMinuteOptions = () => Array.from({ length: 60 / exports.BOOKING_LEAD_TIME_MINUTE_STEP }, (_, i) => {
25
+ const minutes = i * exports.BOOKING_LEAD_TIME_MINUTE_STEP;
26
+ return { key: minutes, content: minutes };
27
+ });
28
+ exports.buildBookingLeadMinuteOptions = buildBookingLeadMinuteOptions;
29
+ const resolveBlockingMinutes = (rule) => {
30
+ if (rule?.blockingMinutes != null)
31
+ return rule.blockingMinutes;
32
+ return (rule?.blockingHours ?? 0) * 60;
33
+ };
34
+ exports.resolveBlockingMinutes = resolveBlockingMinutes;
35
+ const resolveMinMinutesForBooking = (rule) => {
36
+ if (rule?.minMinutesForBooking != null)
37
+ return rule.minMinutesForBooking;
38
+ return (rule?.minHoursForBooking ?? 0) * 60;
39
+ };
40
+ exports.resolveMinMinutesForBooking = resolveMinMinutesForBooking;
41
+ /** Человекочитаемый интервал до начала игры для таблиц и описаний. */
42
+ const formatBookingLeadTime = (totalMinutes, t) => {
43
+ const { hours, minutes } = (0, exports.minutesToBookingLeadParts)(totalMinutes);
44
+ if (hours > 0 && minutes > 0) {
45
+ return `${hours} ${t('часов')} ${minutes} ${t('мин')}`;
46
+ }
47
+ if (hours > 0) {
48
+ return `${hours} ${t('часов')}`;
49
+ }
50
+ return `${minutes} ${t('мин')}`;
51
+ };
52
+ exports.formatBookingLeadTime = formatBookingLeadTime;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const index_1 = require("./index");
4
+ describe('booking-lead-time', () => {
5
+ it('splits and merges hours and minutes', () => {
6
+ expect((0, index_1.minutesToBookingLeadParts)(90)).toEqual({ hours: 1, minutes: 30 });
7
+ expect((0, index_1.bookingLeadPartsToMinutes)(1, 30)).toBe(90);
8
+ });
9
+ it('rounds to 5-minute step', () => {
10
+ expect((0, index_1.roundBookingLeadMinutesToStep)(32)).toBe(30);
11
+ expect((0, index_1.roundBookingLeadMinutesToStep)(33)).toBe(35);
12
+ });
13
+ it('resolves minutes with legacy hours fallback', () => {
14
+ expect((0, index_1.resolveBlockingMinutes)({ blockingHours: 2 })).toBe(120);
15
+ expect((0, index_1.resolveMinMinutesForBooking)({ minHoursForBooking: 48 })).toBe(2880);
16
+ expect((0, index_1.resolveBlockingMinutes)({ blockingMinutes: 30, blockingHours: 2 })).toBe(30);
17
+ });
18
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Конвертер legacy HTML (Quill output, использовавшийся в marketing-
3
+ * emails: cross-sale, retargeting, up-sale, early-booking, birthday,
4
+ * birthdayChild, custom-client-booking, scenario-emails) в формат
5
+ * `EmailContentJsonV2` нового редактора (Maily/TipTap).
6
+ *
7
+ * Что обрабатывает:
8
+ * - Параграфы `<p>` + переносы `<br>`, схлопывает пустые абзацы
9
+ * - Inline-форматирование `<strong>`/`<em>`/`<u>`/`<s>` (marks)
10
+ * - Ссылки `<a href="…">` → link-mark
11
+ * - Изображения `<img>` → image-нода
12
+ * - Плоские плейсхолдеры `{varName}` → `variable`-ноды
13
+ * - Maily-style плейсхолдеры `{{varName}}` и `{{varName|"fallback"}}`
14
+ * — тоже превращаются в `variable`-ноды (с учётом fallback'а)
15
+ * - Управляющие конструкции:
16
+ * • `{{#if X}}…{{/if}}` → `section` нода с `showIfKey: 'X'`
17
+ * • `{{#manageBooking}}текст{{/manageBooking}}` → `button` нода
18
+ * c `isUrlVariable: true, url: 'bookingManagementLink'`
19
+ * - Блочные HTML-плейсхолдеры (`orderDetailsHtml`) — выносятся в
20
+ * отдельный `htmlCodeBlock` (их значение содержит `<div>`/`<table>`,
21
+ * нельзя оставить внутри `<p>`). `servicesList` тут не упоминается:
22
+ * функция up-sale-листа в письмах выпилена, см. `isServicesListVariableNode`
23
+ * - Цвета/фоны/font-size — выкидываются (как `clearQuill stripColors`)
24
+ *
25
+ * Что НЕ обрабатывает (по дизайну):
26
+ * - Списки `<ul>`/`<ol>` — в legacy шаблонах не встречаются (Quill в
27
+ * нашей конфигурации их не выставлял). Если попадутся — fallback на
28
+ * обычный текст; добавим если потребуется по факту.
29
+ * - Таблицы — то же самое.
30
+ * - Заголовки `<h1>`-`<h6>` — Quill их не использовал в шаблонах.
31
+ *
32
+ * Преобразование чистое и идемпотентное: повторный вызов на той же
33
+ * строке даёт тот же результат.
34
+ */
35
+ import { EmailContentJsonV2 } from '@escapenavigator/types/dist/email-builder';
36
+ import { BLOCK_HTML_PLACEHOLDERS, isBlockHtmlPlaceholder, isKnownPlaceholder, KNOWN_PLACEHOLDERS, LEGACY_TO_NAMESPACED_ID_MAP, normalizePlaceholderId } from './placeholders';
37
+ /**
38
+ * Опции конвертера. Все необязательны — поведение «без аргументов»
39
+ * выдаёт корректный `EmailContentJsonV2` с дефолтной темой и без
40
+ * логотипа сверху (минимальный набор).
41
+ */
42
+ export type ConvertLegacyHtmlOptions = {
43
+ /**
44
+ * URL логотипа. Если задан — первой нодой в `doc.content` идёт
45
+ * `image` с точно теми же параметрами, что и `createLogoNode`
46
+ * (alignment/size/alt). Это нужно, чтобы после миграции v2-доки
47
+ * визуально совпадали с seed'ом `createDefaultEmailContent` —
48
+ * пользователь не должен заметить, что шаблон конвертился из
49
+ * старого формата.
50
+ */
51
+ logo?: string | null;
52
+ /**
53
+ * Карта человекочитаемых лейблов для `variable.label`. Если для
54
+ * ключа лейбла нет — будет использоваться сам ID. На бэке миграции
55
+ * сюда удобно прокидывать резолверовские `declare()` лейблы; на
56
+ * фронте — `t('crm-crosssales:emailConstructor.<key>.title')`.
57
+ *
58
+ * NB: лейблы ищутся по **уже отнормализованному** id (после
59
+ * `idMap`), так как этот id и попадёт в JSON.
60
+ */
61
+ labels?: Readonly<Record<string, string>>;
62
+ /**
63
+ * Маппинг flat-id'ов → namespaced. По умолчанию используется
64
+ * `LEGACY_TO_NAMESPACED_ID_MAP`. Передать `null` чтобы выключить
65
+ * маппинг (полезно для тестов или ad-hoc конверсий вне marketing-
66
+ * email пайплайна).
67
+ */
68
+ idMap?: Readonly<Record<string, string>> | null;
69
+ /**
70
+ * «Зашить» значения некоторых плейсхолдеров прямо в текст вместо
71
+ * `variable`-ноды. Ключи проверяются **до** маппинга через `idMap`
72
+ * (по исходному legacy-id, т.к. inline-замена нужна для legacy-
73
+ * понятий вроде `profileTitle`, у которых больше нет резолвера).
74
+ *
75
+ * Пример: `{ profileTitle: 'Escape Quest' }` превратит
76
+ * `{{profileTitle}}` в обычную текстовую ноду «Escape Quest».
77
+ * Полезно для бекфилла, когда переменная упразднена, но в шаблонах
78
+ * она встречается миллион раз — пользователь не должен видеть
79
+ * пустую дыру или технический id после конверсии.
80
+ */
81
+ inlineValues?: Readonly<Record<string, string>>;
82
+ };
83
+ /**
84
+ * Идемпотентный нормализатор устаревших variable-чипов, у которых
85
+ * есть выделенное block-представление в Maily:
86
+ * - `{photos}` / `{order.photoFirst}` → одна `image`-нода
87
+ * (`src = photos`, 200×200);
88
+ * - `{servicesList}` → удаляется (функция up-sale-листа в письмах
89
+ * выпилена; см. `isServicesListVariableNode` и factory `() => []`
90
+ * в `LEGACY_BLOCK_VARIABLE_REPLACERS`).
91
+ *
92
+ * Используется во всех трёх каналах:
93
+ * - финальный шаг `convertLegacyHtmlToEmailContent` — гарантия для
94
+ * свежих конверсий из legacy HTML;
95
+ * - `hydrateEmailLocales` на фронте — лечит уже-сохранённые
96
+ * contentJson при открытии формы;
97
+ * - `renderEmailV2Local` на бэке — последний рубеж перед send,
98
+ * спасает scheduled / queue-задачи, идущие в обход UI.
99
+ *
100
+ * Имя сохранено по историческим причинам (когда-то функция занималась
101
+ * только photos). Сейчас правильнее читать его как «migrate legacy
102
+ * block variables» — добавление новой замены сводится к одной строке
103
+ * в `LEGACY_BLOCK_VARIABLE_REPLACERS`.
104
+ *
105
+ * Правила обхода:
106
+ * 1. Top-level `variable[id ∈ matchLegacyBlockVariable]` или
107
+ * `htmlCodeBlock` с такой variable внутри (артефакт `promoteBlockHtmlVariables`)
108
+ * — заменяются полностью на соответствующий block-узел.
109
+ * 2. `paragraph` с единственным непустым ребёнком-legacy-variable —
110
+ * целиком заменяется на block-узел (image/repeat — block-level,
111
+ * жить inline в параграфе не могут).
112
+ * 3. Variable посреди смешанного параграфа — оставляем как есть:
113
+ * это пользовательский кейс, безопаснее не ломать layout.
114
+ * 4. Внутрь `section`/`repeat`/`for`/`show` и т.п. block-контейнеров
115
+ * ходим рекурсивно.
116
+ *
117
+ * Возвращает НОВЫЙ объект, не мутирует входной. Идемпотентна:
118
+ * повторный вызов — no-op (после первой замены variable-чипов
119
+ * с такими id уже не остаётся).
120
+ */
121
+ export declare function migratePhotosToImage(content: EmailContentJsonV2): EmailContentJsonV2;
122
+ /**
123
+ * Главная точка входа конвертера. Принимает legacy HTML (Quill-style
124
+ * с плоскими плейсхолдерами и handlebars-блоками) и возвращает готовый
125
+ * `EmailContentJsonV2`, готовый к сохранению в БД и открытию в
126
+ * EmailBuilder.
127
+ *
128
+ * Идемпотентен: пустой/null/whitespace вход даёт минимальный валидный
129
+ * `doc` (с логотипом если указан, иначе пустой массив content).
130
+ *
131
+ * Шаги обработки (в порядке выполнения):
132
+ * 1. `preprocessHtml` — text-level замены (`{{#if}}`, `{var}`, ...)
133
+ * 2. parse через node-html-parser
134
+ * 3. `walk` — построение TipTap-нод верхнего уровня
135
+ * 4. `promoteBlockHtmlVariables` — вынос block-payload плейсхолдеров
136
+ * 5. `normalizeVariableIds` — flat → namespaced id (если включено)
137
+ * 6. оборачивание в `EmailContentJsonV2` с логотипом и темой
138
+ */
139
+ export declare function convertLegacyHtmlToEmailContent(html: string | null | undefined, options?: ConvertLegacyHtmlOptions): EmailContentJsonV2;
140
+ /**
141
+ * Подсчёт неизвестных плейсхолдеров в legacy HTML. Возвращает уникальные
142
+ * имена `{X}` и `{{X}}`, которые **не входят** в `KNOWN_PLACEHOLDERS`.
143
+ * Используется до миграции, чтобы найти ручные/опечатанные токены в
144
+ * шаблонах клиентов и решить, что с ними делать (исправить → конвертим,
145
+ * проигнорить → оставляем как литеральный текст).
146
+ */
147
+ export declare function findUnknownPlaceholders(html: string | null | undefined): string[];
148
+ export type { KnownPlaceholder } from './placeholders';
149
+ export { BLOCK_HTML_PLACEHOLDERS, isBlockHtmlPlaceholder, isKnownPlaceholder, KNOWN_PLACEHOLDERS, LEGACY_TO_NAMESPACED_ID_MAP, normalizePlaceholderId, };