@escapenavigator/utils 1.10.130 → 1.10.132

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,15 @@
1
+ /**
2
+ * Shared chrome for interactive Popover panels (pickers, calendars, menus).
3
+ * Use on the inner content wrapper (`ref={popoverRef}`), not on Tooltip.
4
+ */
5
+ export declare const functionalPopoverPanelStyle: {
6
+ readonly background: "var(--color-bg-primary)";
7
+ readonly border: "1px solid var(--color-border-primary)";
8
+ readonly borderRadius: "var(--radius-m)";
9
+ readonly boxShadow: "var(--shadow-m)";
10
+ readonly padding: 12;
11
+ readonly boxSizing: "border-box";
12
+ readonly color: "var(--color-text-primary)";
13
+ };
14
+ export type FunctionalPopoverPanelStyle = typeof functionalPopoverPanelStyle;
15
+ export declare const functionalPopoverPanel: (extra?: Record<string, string | number>) => Record<string, string | number>;
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.functionalPopoverPanel = exports.functionalPopoverPanelStyle = void 0;
4
+ /**
5
+ * Shared chrome for interactive Popover panels (pickers, calendars, menus).
6
+ * Use on the inner content wrapper (`ref={popoverRef}`), not on Tooltip.
7
+ */
8
+ exports.functionalPopoverPanelStyle = {
9
+ background: 'var(--color-bg-primary)',
10
+ border: '1px solid var(--color-border-primary)',
11
+ borderRadius: 'var(--radius-m)',
12
+ boxShadow: 'var(--shadow-m)',
13
+ padding: 12,
14
+ boxSizing: 'border-box',
15
+ color: 'var(--color-text-primary)',
16
+ };
17
+ const functionalPopoverPanel = (extra) => ({
18
+ ...exports.functionalPopoverPanelStyle,
19
+ ...extra,
20
+ });
21
+ exports.functionalPopoverPanel = functionalPopoverPanel;
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@ export * from './convert-minutes-to-hhmm';
4
4
  export * from './convert-to-options';
5
5
  export * from './date';
6
6
  export * from './enum-to-options';
7
+ export * from './functional-popover-panel-style';
7
8
  export * from './get-documents-links';
8
9
  export * from './get-full-name';
9
10
  export * from './get-handled-error-message';
@@ -17,7 +18,9 @@ export * from './promocode-nominal-rules';
17
18
  export * from './redirect';
18
19
  export * from './serialize-record';
19
20
  export * from './serialize-slot';
21
+ export * from './slot-representative-price';
20
22
  export * from './tz-date';
23
+ export * from './user-session-permissions';
21
24
  export * from './utm-touchpoints';
22
25
  export * from './validate-by-dto';
23
26
  export * from './validate-promocode';
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ __exportStar(require("./convert-minutes-to-hhmm"), exports);
20
20
  __exportStar(require("./convert-to-options"), exports);
21
21
  __exportStar(require("./date"), exports);
22
22
  __exportStar(require("./enum-to-options"), exports);
23
+ __exportStar(require("./functional-popover-panel-style"), exports);
23
24
  __exportStar(require("./get-documents-links"), exports);
24
25
  __exportStar(require("./get-full-name"), exports);
25
26
  __exportStar(require("./get-handled-error-message"), exports);
@@ -33,7 +34,9 @@ __exportStar(require("./promocode-nominal-rules"), exports);
33
34
  __exportStar(require("./redirect"), exports);
34
35
  __exportStar(require("./serialize-record"), exports);
35
36
  __exportStar(require("./serialize-slot"), exports);
37
+ __exportStar(require("./slot-representative-price"), exports);
36
38
  __exportStar(require("./tz-date"), exports);
39
+ __exportStar(require("./user-session-permissions"), exports);
37
40
  __exportStar(require("./utm-touchpoints"), exports);
38
41
  __exportStar(require("./validate-by-dto"), exports);
39
42
  __exportStar(require("./validate-promocode"), exports);
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Paste-time matchers для ReactQuill (`modules.clipboard.matchers`).
3
+ *
4
+ * Вынесено в общий пакет, чтобы admin и app использовали один и тот же набор
5
+ * matcher-ов без дублирования. Делает две вещи на входе из буфера обмена:
6
+ *
7
+ * 1) Снимает inline-цвета и фоны (`color: false` / `background: false` через
8
+ * compose Delta). Это лечит вставку из Word/Google Docs, которая по
9
+ * умолчанию приходит с собственной палитрой и часто не читается на наших
10
+ * темах (особенно тёмной теме ордеров и контрастных формах админки).
11
+ *
12
+ * 2) Заменяет неразрывные пробелы (`\u00A0`) на обычные прямо в Delta до
13
+ * того, как Quill сохранит ноду. Без этого `&nbsp;` остаётся в HTML и
14
+ * ломает word-wrap длинных заголовков и абзацев на странице рендера
15
+ * (см. также `sanitizeQuillHtml` на стороне рендеринга — оно ловит то же
16
+ * самое, но уже на исторических данных, которые попали в БД до того,
17
+ * как paste-matcher появился).
18
+ *
19
+ * ELEMENT_NODE matcher срабатывает на всех элементах (страховой случай),
20
+ * SPAN/FONT — прицельно (Word любит оборачивать раскраску в `<span style="…">`
21
+ * и `<font color="…">`), TEXT_NODE — для нормализации пробелов в тексте.
22
+ */
23
+ import Delta from 'quill-delta';
24
+ export type ClipboardMatcher = [number | string, (node: Node, delta: Delta) => Delta];
25
+ /**
26
+ * Полный набор matcher-ов: и color-strip, и nbsp-normalize. Подключается там,
27
+ * где редактор не должен принимать цветную раскраску из буфера (waiver, AGB,
28
+ * локации, контентные модалки).
29
+ */
30
+ export declare function getStripColorClipboardMatchers(): ClipboardMatcher[];
31
+ /**
32
+ * Только нормализация пробельных сущностей. Подключается там, где цветную
33
+ * раскраску в буфере мы готовы пустить дальше (rich email-редактор админа),
34
+ * но nbsp всё равно надо чистить, чтобы не ломать word-wrap.
35
+ */
36
+ export declare function getNormalizeWhitespaceClipboardMatchers(): ClipboardMatcher[];
37
+ /**
38
+ * Готовые `modules` для прямой подстановки в `<ReactQuill modules={…} />`.
39
+ *
40
+ * Содержит два изменения относительно дефолтного snow-конфига:
41
+ *
42
+ * 1) Тулбар без пикеров `color` / `background`. У нас цветной email-редактор
43
+ * живёт ровно в одном месте (`admin/modals/email-send-form`); во всех
44
+ * остальных формах цвета только мешают — пользователи случайно красили
45
+ * текст оранжевым на белом фоне, а потом он становился нечитаемым в
46
+ * тёмной теме клиента (orders/widget) или в письме на чёрном фоне.
47
+ *
48
+ * 2) Clipboard-matchers, которые на вставку из буфера снимают inline-цвета
49
+ * и нормализуют `\u00A0` → ` ` (см. `getStripColorClipboardMatchers`).
50
+ */
51
+ export declare const stripColorQuillModules: {
52
+ toolbar: (string[] | {
53
+ header: (number | boolean)[];
54
+ }[] | {
55
+ list: string;
56
+ }[] | {
57
+ indent: string;
58
+ }[] | {
59
+ align: any[];
60
+ }[])[];
61
+ clipboard: {
62
+ matchers: ClipboardMatcher[];
63
+ };
64
+ };
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ /**
3
+ * Paste-time matchers для ReactQuill (`modules.clipboard.matchers`).
4
+ *
5
+ * Вынесено в общий пакет, чтобы admin и app использовали один и тот же набор
6
+ * matcher-ов без дублирования. Делает две вещи на входе из буфера обмена:
7
+ *
8
+ * 1) Снимает inline-цвета и фоны (`color: false` / `background: false` через
9
+ * compose Delta). Это лечит вставку из Word/Google Docs, которая по
10
+ * умолчанию приходит с собственной палитрой и часто не читается на наших
11
+ * темах (особенно тёмной теме ордеров и контрастных формах админки).
12
+ *
13
+ * 2) Заменяет неразрывные пробелы (`\u00A0`) на обычные прямо в Delta до
14
+ * того, как Quill сохранит ноду. Без этого `&nbsp;` остаётся в HTML и
15
+ * ломает word-wrap длинных заголовков и абзацев на странице рендера
16
+ * (см. также `sanitizeQuillHtml` на стороне рендеринга — оно ловит то же
17
+ * самое, но уже на исторических данных, которые попали в БД до того,
18
+ * как paste-matcher появился).
19
+ *
20
+ * ELEMENT_NODE matcher срабатывает на всех элементах (страховой случай),
21
+ * SPAN/FONT — прицельно (Word любит оборачивать раскраску в `<span style="…">`
22
+ * и `<font color="…">`), TEXT_NODE — для нормализации пробелов в тексте.
23
+ */
24
+ var __importDefault = (this && this.__importDefault) || function (mod) {
25
+ return (mod && mod.__esModule) ? mod : { "default": mod };
26
+ };
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.stripColorQuillModules = void 0;
29
+ exports.getStripColorClipboardMatchers = getStripColorClipboardMatchers;
30
+ exports.getNormalizeWhitespaceClipboardMatchers = getNormalizeWhitespaceClipboardMatchers;
31
+ const quill_delta_1 = __importDefault(require("quill-delta"));
32
+ const stripColorFromDelta = (_node, delta) => delta.compose(new quill_delta_1.default().retain(delta.length(), {
33
+ color: false,
34
+ background: false,
35
+ }));
36
+ const normalizeWhitespaceInDelta = (_node, delta) => {
37
+ if (!delta?.ops?.length)
38
+ return delta;
39
+ let modified = false;
40
+ const ops = delta.ops.map((op) => {
41
+ if (typeof op.insert === 'string' && op.insert.indexOf('\u00A0') !== -1) {
42
+ modified = true;
43
+ return { ...op, insert: op.insert.replace(/\u00A0/g, ' ') };
44
+ }
45
+ return op;
46
+ });
47
+ return modified ? new quill_delta_1.default(ops) : delta;
48
+ };
49
+ /**
50
+ * Полный набор matcher-ов: и color-strip, и nbsp-normalize. Подключается там,
51
+ * где редактор не должен принимать цветную раскраску из буфера (waiver, AGB,
52
+ * локации, контентные модалки).
53
+ */
54
+ function getStripColorClipboardMatchers() {
55
+ return [
56
+ [Node.ELEMENT_NODE, stripColorFromDelta],
57
+ ['SPAN', stripColorFromDelta],
58
+ ['FONT', stripColorFromDelta],
59
+ [Node.TEXT_NODE, normalizeWhitespaceInDelta],
60
+ ];
61
+ }
62
+ /**
63
+ * Только нормализация пробельных сущностей. Подключается там, где цветную
64
+ * раскраску в буфере мы готовы пустить дальше (rich email-редактор админа),
65
+ * но nbsp всё равно надо чистить, чтобы не ломать word-wrap.
66
+ */
67
+ function getNormalizeWhitespaceClipboardMatchers() {
68
+ return [[Node.TEXT_NODE, normalizeWhitespaceInDelta]];
69
+ }
70
+ /**
71
+ * Готовые `modules` для прямой подстановки в `<ReactQuill modules={…} />`.
72
+ *
73
+ * Содержит два изменения относительно дефолтного snow-конфига:
74
+ *
75
+ * 1) Тулбар без пикеров `color` / `background`. У нас цветной email-редактор
76
+ * живёт ровно в одном месте (`admin/modals/email-send-form`); во всех
77
+ * остальных формах цвета только мешают — пользователи случайно красили
78
+ * текст оранжевым на белом фоне, а потом он становился нечитаемым в
79
+ * тёмной теме клиента (orders/widget) или в письме на чёрном фоне.
80
+ *
81
+ * 2) Clipboard-matchers, которые на вставку из буфера снимают inline-цвета
82
+ * и нормализуют `\u00A0` → ` ` (см. `getStripColorClipboardMatchers`).
83
+ */
84
+ exports.stripColorQuillModules = {
85
+ toolbar: [
86
+ [{ header: [1, 2, 3, false] }],
87
+ ['bold', 'italic', 'underline', 'strike'],
88
+ [{ list: 'ordered' }, { list: 'bullet' }],
89
+ [{ indent: '-1' }, { indent: '+1' }],
90
+ ['blockquote', 'code-block'],
91
+ [{ align: [] }],
92
+ ['link'],
93
+ ['clean'],
94
+ ],
95
+ clipboard: {
96
+ matchers: getStripColorClipboardMatchers(),
97
+ },
98
+ };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Чистим Quill-HTML, который был сохранён без `clearQuill` или с устаревшей
3
+ * его версией (исторические waiver/AGB/`importantInfo`/локации/маркетинг-письма).
4
+ *
5
+ * Делит ответственность на две задачи, обе доступны и по отдельности:
6
+ *
7
+ * 1) `stripQuillColors` — снимает цвета и фоны: `style="color:…/background:…/
8
+ * background-color:…"`, атрибуты `color="…"`/`data-color`/`data-background`
9
+ * и Quill-классы `ql-color-*` / `ql-background-*`. Нужно везде, где тема
10
+ * рендера отличается от темы редактора (тёмная тема ордеров на белом фоне
11
+ * админки, инвертированные письма и т.п.).
12
+ *
13
+ * 2) `normalizeQuillWhitespace` — лечит неразрывные пробелы (`&nbsp;`, U+00A0)
14
+ * и одиночные `<br>` внутри `<p>`/`<h*>`. nbsp браузер не считает точкой
15
+ * переноса, и длинный заголовок вроде `14.&nbsp;ФОТО-,&nbsp;ВИДЕОФИКСАЦИЯ…`
16
+ * превращается в одно «слово» и вылезает за контейнер. То же самое
17
+ * случалось из-за вырезанных `<br>` в старом `clearQuill` — соседние строки
18
+ * склеивались без разделителя. Меняем nbsp и одиночные `<br>` на пробел,
19
+ * пустые `<p>`/`<p><br></p>` удаляем, дубли пробелов в тексте схлопываем.
20
+ *
21
+ * Содержимое `<pre>`/`<code>`/`<style>`/`<script>` не трогаем — там пробелы
22
+ * значимы.
23
+ *
24
+ * Все функции — чистые и идемпотентные: повторный вызов на уже очищенном
25
+ * HTML возвращает тот же результат (важно для сравнения «изменился ли текст»
26
+ * на стороне клиентских настроек, см. `client-agreement.tsx`).
27
+ */
28
+ export type SanitizeQuillHtmlOptions = {
29
+ /** Снять inline-цвета/фоны и `ql-color-*`/`ql-background-*` классы. */
30
+ stripColors?: boolean;
31
+ };
32
+ export declare function stripQuillColors(html: string): string;
33
+ export declare function normalizeQuillWhitespace(html: string): string;
34
+ export declare function sanitizeQuillHtml(html: string | null | undefined, options?: SanitizeQuillHtmlOptions): string;
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ /**
3
+ * Чистим Quill-HTML, который был сохранён без `clearQuill` или с устаревшей
4
+ * его версией (исторические waiver/AGB/`importantInfo`/локации/маркетинг-письма).
5
+ *
6
+ * Делит ответственность на две задачи, обе доступны и по отдельности:
7
+ *
8
+ * 1) `stripQuillColors` — снимает цвета и фоны: `style="color:…/background:…/
9
+ * background-color:…"`, атрибуты `color="…"`/`data-color`/`data-background`
10
+ * и Quill-классы `ql-color-*` / `ql-background-*`. Нужно везде, где тема
11
+ * рендера отличается от темы редактора (тёмная тема ордеров на белом фоне
12
+ * админки, инвертированные письма и т.п.).
13
+ *
14
+ * 2) `normalizeQuillWhitespace` — лечит неразрывные пробелы (`&nbsp;`, U+00A0)
15
+ * и одиночные `<br>` внутри `<p>`/`<h*>`. nbsp браузер не считает точкой
16
+ * переноса, и длинный заголовок вроде `14.&nbsp;ФОТО-,&nbsp;ВИДЕОФИКСАЦИЯ…`
17
+ * превращается в одно «слово» и вылезает за контейнер. То же самое
18
+ * случалось из-за вырезанных `<br>` в старом `clearQuill` — соседние строки
19
+ * склеивались без разделителя. Меняем nbsp и одиночные `<br>` на пробел,
20
+ * пустые `<p>`/`<p><br></p>` удаляем, дубли пробелов в тексте схлопываем.
21
+ *
22
+ * Содержимое `<pre>`/`<code>`/`<style>`/`<script>` не трогаем — там пробелы
23
+ * значимы.
24
+ *
25
+ * Все функции — чистые и идемпотентные: повторный вызов на уже очищенном
26
+ * HTML возвращает тот же результат (важно для сравнения «изменился ли текст»
27
+ * на стороне клиентских настроек, см. `client-agreement.tsx`).
28
+ */
29
+ Object.defineProperty(exports, "__esModule", { value: true });
30
+ exports.stripQuillColors = stripQuillColors;
31
+ exports.normalizeQuillWhitespace = normalizeQuillWhitespace;
32
+ exports.sanitizeQuillHtml = sanitizeQuillHtml;
33
+ const COLOR_STYLE_PROPS = new Set(['color', 'background', 'background-color']);
34
+ const COLOR_CLASS_PATTERN = /^ql-(color|background)-/;
35
+ const PRESERVE_BLOCK_PATTERN = /<(pre|code|style|script)\b[^>]*>[\s\S]*?<\/\1>/gi;
36
+ const PRESERVE_TOKEN_PREFIX = '\u2603SANITIZE_QUILL_PRESERVE_';
37
+ const PRESERVE_TOKEN_SUFFIX = '\u2603';
38
+ const PRESERVE_TOKEN_PATTERN = /\u2603SANITIZE_QUILL_PRESERVE_(\d+)\u2603/g;
39
+ function cleanStyleAttribute(style) {
40
+ return style
41
+ .split(';')
42
+ .map((part) => part.trim())
43
+ .filter(Boolean)
44
+ .filter((part) => {
45
+ const prop = part.split(':')[0]?.trim().toLowerCase() ?? '';
46
+ return prop && !COLOR_STYLE_PROPS.has(prop);
47
+ })
48
+ .join('; ');
49
+ }
50
+ function stripQuillColors(html) {
51
+ return html
52
+ .replace(/\s*style="([^"]*)"/gi, (_match, styleContent) => {
53
+ const cleaned = cleanStyleAttribute(styleContent);
54
+ return cleaned ? ` style="${cleaned}"` : '';
55
+ })
56
+ .replace(/\s*color="[^"]*"/gi, '')
57
+ .replace(/\s*class="([^"]*)"/gi, (_match, classContent) => {
58
+ const cleaned = classContent
59
+ .split(/\s+/)
60
+ .filter((token) => token && !COLOR_CLASS_PATTERN.test(token))
61
+ .join(' ');
62
+ return cleaned ? ` class="${cleaned}"` : '';
63
+ })
64
+ .replace(/\s*data-color="[^"]*"/gi, '')
65
+ .replace(/\s*data-background="[^"]*"/gi, '')
66
+ .replace(/<span>\s*<\/span>/gi, '')
67
+ .replace(/<font[^>]*>([\s\S]*?)<\/font>/gi, '$1');
68
+ }
69
+ function normalizeQuillWhitespace(html) {
70
+ const placeholders = [];
71
+ const withPlaceholders = html.replace(PRESERVE_BLOCK_PATTERN, (match) => {
72
+ const token = `${PRESERVE_TOKEN_PREFIX}${placeholders.length}${PRESERVE_TOKEN_SUFFIX}`;
73
+ placeholders.push(match);
74
+ return token;
75
+ });
76
+ const normalized = withPlaceholders
77
+ .replace(/&nbsp;|&#160;|&#xa0;|\u00A0/gi, ' ')
78
+ .replace(/<br\s*\/?>(?!\s*<\/p>)/gi, ' ')
79
+ .replace(/<p><br\s*\/?><\/p>/gi, '')
80
+ .replace(/<p>\s*<\/p>/gi, '')
81
+ .replace(/<br\s*\/?>/gi, '')
82
+ .replace(/>([^<]+)</g, function (_match, text) {
83
+ return `>${text.replace(/[ \t]{2,}/g, ' ')}<`;
84
+ });
85
+ return normalized.replace(PRESERVE_TOKEN_PATTERN, function (_match, idx) {
86
+ return placeholders[Number(idx)] || '';
87
+ });
88
+ }
89
+ /**
90
+ * Кэш результата по композитному ключу (опции + html). За один рендер
91
+ * страницы один и тот же `importantInfo`/`prepareText` может попасть в
92
+ * Typography.Text несколько раз (sidebar + основной блок), а контент
93
+ * договоров — это десятки КБ HTML. Ключи — конкретные строки полей из API,
94
+ * их за сессию ограниченное число, освобождаются с закрытием вкладки.
95
+ */
96
+ const cache = new Map();
97
+ function sanitizeQuillHtml(html, options) {
98
+ if (!html)
99
+ return '';
100
+ const stripColors = options?.stripColors ? '1' : '0';
101
+ const key = `${stripColors}|${html}`;
102
+ const cached = cache.get(key);
103
+ if (cached !== undefined)
104
+ return cached;
105
+ const colorStripped = options?.stripColors ? stripQuillColors(html) : html;
106
+ const result = normalizeQuillWhitespace(colorStripped);
107
+ cache.set(key, result);
108
+ return result;
109
+ }
@@ -0,0 +1,10 @@
1
+ type SlotPriceSource = {
2
+ tariff?: unknown;
3
+ basePrice?: number;
4
+ discount?: number;
5
+ };
6
+ /** Минимальная положительная цена из тарифа (без скидки слота). */
7
+ export declare const getTariffBasePrice: (tariff: unknown) => number | null;
8
+ /** Цена «от» для слота в расписании (с учётом скидки слота). */
9
+ export declare const getSlotRepresentativePrice: (slot: SlotPriceSource) => number | null;
10
+ export {};
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getSlotRepresentativePrice = exports.getTariffBasePrice = void 0;
4
+ const applySlotDiscount = (base, discount) => {
5
+ const value = discount || 0;
6
+ if (!value)
7
+ return base;
8
+ return base - Math.floor((base / 10000) * value);
9
+ };
10
+ /** Минимальная положительная цена из тарифа (без скидки слота). */
11
+ const getTariffBasePrice = (tariff) => {
12
+ if (!tariff || typeof tariff !== 'object')
13
+ return null;
14
+ const raw = tariff;
15
+ const priceMap = raw.price && typeof raw.price === 'object' && raw.price !== null
16
+ ? raw.price
17
+ : raw;
18
+ const keys = Object.keys(priceMap)
19
+ .filter((k) => k !== 'child' && !Number.isNaN(Number(k)))
20
+ .map(Number)
21
+ .sort((a, b) => a - b);
22
+ for (const key of keys) {
23
+ const base = Number(priceMap[String(key)]);
24
+ if (Number.isFinite(base) && base > 0)
25
+ return base;
26
+ }
27
+ return null;
28
+ };
29
+ exports.getTariffBasePrice = getTariffBasePrice;
30
+ /** Цена «от» для слота в расписании (с учётом скидки слота). */
31
+ const getSlotRepresentativePrice = (slot) => {
32
+ const base = typeof slot.basePrice === 'number' && slot.basePrice > 0
33
+ ? slot.basePrice
34
+ : (0, exports.getTariffBasePrice)(slot.tariff);
35
+ if (base == null)
36
+ return null;
37
+ return applySlotDiscount(base, slot.discount);
38
+ };
39
+ exports.getSlotRepresentativePrice = getSlotRepresentativePrice;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const slot_representative_price_1 = require("./slot-representative-price");
4
+ describe('slot-representative-price', () => {
5
+ it('reads price from tariff.price map', () => {
6
+ expect((0, slot_representative_price_1.getTariffBasePrice)({
7
+ id: 1,
8
+ title: 'Default',
9
+ price: { 2: 10000, 3: 8000 },
10
+ })).toBe(10000);
11
+ });
12
+ it('reads price from plain price map', () => {
13
+ expect((0, slot_representative_price_1.getTariffBasePrice)({ 2: 5000, 4: 4000 })).toBe(5000);
14
+ });
15
+ it('skips zero min key and uses next positive price', () => {
16
+ expect((0, slot_representative_price_1.getTariffBasePrice)({ price: { 1: 0, 2: 7500 } })).toBe(7500);
17
+ });
18
+ it('uses basePrice and applies slot discount', () => {
19
+ expect((0, slot_representative_price_1.getSlotRepresentativePrice)({
20
+ basePrice: 10000,
21
+ discount: 1000,
22
+ })).toBe(9000);
23
+ });
24
+ it('coerces string amounts in tariff', () => {
25
+ expect((0, slot_representative_price_1.getTariffBasePrice)({ price: { 2: '9999' } })).toBe(9999);
26
+ });
27
+ });
@@ -0,0 +1,37 @@
1
+ import { RoleRO } from '@escapenavigator/types/dist/role/role.ro';
2
+ export declare const USER_SESSION_ERROR_KEYS: {
3
+ readonly shiftLocked: "shiftLocked";
4
+ readonly noPermission: "noPermission";
5
+ readonly notShiftAuthor: "notShiftAuthor";
6
+ readonly ownShiftPastDate: "ownShiftPastDate";
7
+ readonly cannotAssignOthers: "cannotAssignOthers";
8
+ readonly cannotCreateUnassigned: "cannotCreateUnassigned";
9
+ readonly sessionNotFound: "sessionNotFound";
10
+ };
11
+ export type UserSessionErrorKey = keyof typeof USER_SESSION_ERROR_KEYS;
12
+ export declare const USER_SESSION_ERRORS_EN: Record<UserSessionErrorKey, string>;
13
+ export type UserSessionRolePermissions = Pick<RoleRO, 'totalAccess' | 'canEditWorkedHours' | 'canEditOwnWorkedHours'>;
14
+ export type UserSessionPermissionTarget = {
15
+ hold?: boolean;
16
+ authorId?: number | null;
17
+ date: string;
18
+ };
19
+ export declare const canEditOtherUserSessions: (role: UserSessionRolePermissions) => boolean;
20
+ export declare const isSessionDateTodayOrFuture: (date: string, today?: string) => boolean;
21
+ /** Returns an English denial message, or null when edit/delete is allowed. */
22
+ export declare const getUserSessionEditDenial: (params: {
23
+ session: UserSessionPermissionTarget;
24
+ role: UserSessionRolePermissions;
25
+ userId: number;
26
+ }) => string | null;
27
+ /** Returns an English denial message, or null when creating a shift is allowed. */
28
+ export declare const getUserSessionCreateDenial: (params: {
29
+ assigneeUserId: number | null | undefined;
30
+ role: UserSessionRolePermissions;
31
+ actorId: number;
32
+ }) => string | null;
33
+ export declare const canEditUserSession: (params: {
34
+ session: UserSessionPermissionTarget;
35
+ role: UserSessionRolePermissions;
36
+ userId: number;
37
+ }) => boolean;
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.canEditUserSession = exports.getUserSessionCreateDenial = exports.getUserSessionEditDenial = exports.isSessionDateTodayOrFuture = exports.canEditOtherUserSessions = exports.USER_SESSION_ERRORS_EN = exports.USER_SESSION_ERROR_KEYS = void 0;
4
+ exports.USER_SESSION_ERROR_KEYS = {
5
+ shiftLocked: 'shiftLocked',
6
+ noPermission: 'noPermission',
7
+ notShiftAuthor: 'notShiftAuthor',
8
+ ownShiftPastDate: 'ownShiftPastDate',
9
+ cannotAssignOthers: 'cannotAssignOthers',
10
+ cannotCreateUnassigned: 'cannotCreateUnassigned',
11
+ sessionNotFound: 'sessionNotFound',
12
+ };
13
+ exports.USER_SESSION_ERRORS_EN = {
14
+ shiftLocked: 'This shift is locked. Unlock it before editing or deleting.',
15
+ noPermission: 'You do not have permission to manage work shifts.',
16
+ notShiftAuthor: 'You can only edit shifts that you created. Ask a manager with worked-hours access to change this shift.',
17
+ ownShiftPastDate: 'You can only edit or delete your own shifts on today or future dates.',
18
+ cannotAssignOthers: 'You cannot create or assign shifts for other employees. Enable "Can edit logged time" on your role.',
19
+ cannotCreateUnassigned: 'You cannot create unassigned shifts. Enable "Can edit logged time" on your role.',
20
+ sessionNotFound: 'Work shift not found.',
21
+ };
22
+ const canEditOtherUserSessions = (role) => !!role.totalAccess || !!role.canEditWorkedHours;
23
+ exports.canEditOtherUserSessions = canEditOtherUserSessions;
24
+ const isSessionDateTodayOrFuture = (date, today = formatTodayYmd()) => today <= date;
25
+ exports.isSessionDateTodayOrFuture = isSessionDateTodayOrFuture;
26
+ const formatTodayYmd = () => {
27
+ const d = new Date();
28
+ return `${d.getFullYear()}-${`${d.getMonth() + 1}`.padStart(2, '0')}-${`${d.getDate()}`.padStart(2, '0')}`;
29
+ };
30
+ /** Returns an English denial message, or null when edit/delete is allowed. */
31
+ const getUserSessionEditDenial = (params) => {
32
+ const { session, role, userId } = params;
33
+ if (session.hold) {
34
+ return exports.USER_SESSION_ERRORS_EN.shiftLocked;
35
+ }
36
+ if ((0, exports.canEditOtherUserSessions)(role)) {
37
+ return null;
38
+ }
39
+ if (!role.canEditOwnWorkedHours) {
40
+ return exports.USER_SESSION_ERRORS_EN.noPermission;
41
+ }
42
+ if (session.authorId !== userId) {
43
+ return exports.USER_SESSION_ERRORS_EN.notShiftAuthor;
44
+ }
45
+ if (!(0, exports.isSessionDateTodayOrFuture)(session.date)) {
46
+ return exports.USER_SESSION_ERRORS_EN.ownShiftPastDate;
47
+ }
48
+ return null;
49
+ };
50
+ exports.getUserSessionEditDenial = getUserSessionEditDenial;
51
+ /** Returns an English denial message, or null when creating a shift is allowed. */
52
+ const getUserSessionCreateDenial = (params) => {
53
+ const { assigneeUserId, role, actorId } = params;
54
+ if ((0, exports.canEditOtherUserSessions)(role)) {
55
+ return null;
56
+ }
57
+ if (!role.canEditOwnWorkedHours) {
58
+ return exports.USER_SESSION_ERRORS_EN.noPermission;
59
+ }
60
+ if (assigneeUserId != null && assigneeUserId !== actorId) {
61
+ return exports.USER_SESSION_ERRORS_EN.cannotAssignOthers;
62
+ }
63
+ if (assigneeUserId == null) {
64
+ return exports.USER_SESSION_ERRORS_EN.cannotCreateUnassigned;
65
+ }
66
+ return null;
67
+ };
68
+ exports.getUserSessionCreateDenial = getUserSessionCreateDenial;
69
+ const canEditUserSession = (params) => (0, exports.getUserSessionEditDenial)(params) === null;
70
+ exports.canEditUserSession = canEditUserSession;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escapenavigator/utils",
3
- "version": "1.10.130",
3
+ "version": "1.10.132",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -14,11 +14,12 @@
14
14
  "test": "jest"
15
15
  },
16
16
  "dependencies": {
17
- "@escapenavigator/types": "^1.10.126",
17
+ "@escapenavigator/types": "^1.10.128",
18
18
  "axios": "^0.21.4",
19
19
  "class-transformer": "^0.5.1",
20
20
  "class-validator": "^0.13.2",
21
- "i18next": "^21.6.4"
21
+ "i18next": "^21.6.4",
22
+ "quill-delta": "^5.1.0"
22
23
  },
23
24
  "devDependencies": {
24
25
  "@types/jest": "^29.5.10",
@@ -26,5 +27,5 @@
26
27
  "ts-jest": "^29.1.1",
27
28
  "typescript": "^5.6"
28
29
  },
29
- "gitHead": "725d5bb466c14160683c9b578184c8f432d11915"
30
+ "gitHead": "f314b83d632fc068719426b472f60ab03213d533"
30
31
  }