@escapenavigator/utils 2.0.36 → 2.0.40

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.
Files changed (32) hide show
  1. package/dist/booking-page-url/index.d.ts +12 -0
  2. package/dist/booking-page-url/index.js +7 -1
  3. package/dist/booking-page-url/index.test.js +25 -0
  4. package/dist/classify-api-error.d.ts +3 -2
  5. package/dist/classify-api-error.js +22 -8
  6. package/dist/classify-api-error.spec.js +9 -0
  7. package/dist/email-builder/placeholders.d.ts +1 -1
  8. package/dist/email-builder/placeholders.js +1 -0
  9. package/dist/format-slot-discount/index.d.ts +6 -0
  10. package/dist/format-slot-discount/index.js +15 -0
  11. package/dist/get-slot-discount-amount.d.ts +22 -0
  12. package/dist/get-slot-discount-amount.js +21 -0
  13. package/dist/get-slot-discount-amount.spec.d.ts +1 -0
  14. package/dist/get-slot-discount-amount.spec.js +39 -0
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.js +4 -0
  17. package/dist/package-price/index.d.ts +73 -0
  18. package/dist/package-price/index.js +92 -0
  19. package/dist/package-price/index.spec.d.ts +1 -0
  20. package/dist/package-price/index.spec.js +100 -0
  21. package/dist/role-refund-permissions.d.ts +7 -0
  22. package/dist/role-refund-permissions.js +9 -0
  23. package/dist/role-refund-permissions.spec.d.ts +1 -0
  24. package/dist/role-refund-permissions.spec.js +16 -0
  25. package/dist/serialize-slot.js +6 -0
  26. package/dist/slot-representative-price.d.ts +10 -0
  27. package/dist/slot-representative-price.js +17 -10
  28. package/dist/upselling-lead-time/index.d.ts +36 -0
  29. package/dist/upselling-lead-time/index.js +49 -0
  30. package/dist/upselling-lead-time/index.test.d.ts +1 -0
  31. package/dist/upselling-lead-time/index.test.js +83 -0
  32. package/package.json +3 -3
@@ -19,6 +19,18 @@ export type BuildBookingPageUrlOptions = {
19
19
  lang?: string;
20
20
  tracking?: BookingTrackingParams;
21
21
  consent?: BookingConsentParams;
22
+ /**
23
+ * Промокод, который виджет применит сам при открытии страницы
24
+ * (`PromocodeProvider` читает `?promocode=` и валидирует его через
25
+ * `resolvePromocode`). Нужен для писем: клиенту не приходится
26
+ * копировать код руками — он уже в корзине.
27
+ */
28
+ promocode?: string;
29
+ /**
30
+ * Дата, на которой откроется расписание (`YYYY-MM-DD`). Читается
31
+ * провайдером слотов виджета как `?initialDate=`.
32
+ */
33
+ initialDate?: string;
22
34
  };
23
35
  /**
24
36
  * Собирает tracking-параметры из URLSearchParams (или строки query).
@@ -67,7 +67,7 @@ function appendBookingParamsToUrl(baseUrl, options) {
67
67
  * `{ordersDomain}/booking/{widgetUuid}` + testMode/lang/tracking/consent.
68
68
  */
69
69
  function buildBookingPageUrl(options) {
70
- const { ordersDomain, widgetUuid, testMode, lang, tracking, consent } = options;
70
+ const { ordersDomain, widgetUuid, testMode, lang, tracking, consent, promocode, initialDate } = options;
71
71
  const base = `${normalizeOrdersDomain(ordersDomain)}/booking/${encodeURIComponent(widgetUuid)}`;
72
72
  const url = new URL(base);
73
73
  if (testMode) {
@@ -76,6 +76,12 @@ function buildBookingPageUrl(options) {
76
76
  if (lang) {
77
77
  url.searchParams.set('lang', lang);
78
78
  }
79
+ if (promocode?.trim()) {
80
+ url.searchParams.set('promocode', promocode.trim());
81
+ }
82
+ if (initialDate?.trim()) {
83
+ url.searchParams.set('initialDate', initialDate.trim());
84
+ }
79
85
  return appendBookingParamsToUrl(url.toString(), { tracking, consent });
80
86
  }
81
87
  /**
@@ -32,4 +32,29 @@ describe('buildBookingPageUrl', () => {
32
32
  });
33
33
  expect(url).toBe('https://orders.escapenavigator.com/booking/W123?testMode=true&lang=de&utm_medium=email');
34
34
  });
35
+ it('carries the promocode so the widget applies it on open', () => {
36
+ const url = (0, index_1.buildBookingPageUrl)({
37
+ ordersDomain: 'https://orders.example.com',
38
+ widgetUuid: 'W1',
39
+ promocode: ' SUMMER25 ',
40
+ });
41
+ expect(url).toContain('promocode=SUMMER25');
42
+ });
43
+ it('carries initialDate to preselect the schedule day', () => {
44
+ const url = (0, index_1.buildBookingPageUrl)({
45
+ ordersDomain: 'https://orders.example.com',
46
+ widgetUuid: 'W1',
47
+ initialDate: '2026-09-01',
48
+ });
49
+ expect(url).toContain('initialDate=2026-09-01');
50
+ });
51
+ it('omits blank promocode and initialDate', () => {
52
+ const url = (0, index_1.buildBookingPageUrl)({
53
+ ordersDomain: 'https://orders.example.com',
54
+ widgetUuid: 'W1',
55
+ promocode: ' ',
56
+ initialDate: '',
57
+ });
58
+ expect(url).toBe('https://orders.example.com/booking/W1');
59
+ });
35
60
  });
@@ -24,8 +24,9 @@
24
24
  * skip → не репортим
25
25
  * 304 (etag-success), отменённые axios-запросы (ERR_CANCELED),
26
26
  * 401/403 на app/auth (ожидаемый session/role flow), а также
27
- * обрывы связи на стороне клиента: `Network Error` и
28
- * «timeout of 0ms exceeded» (запрос умер до ответа сервера).
27
+ * обрывы связи на стороне клиента: `Network Error`,
28
+ * «timeout of 0ms exceeded» и «Request aborted» (запрос умер
29
+ * до ответа сервера).
29
30
  *
30
31
  * Группировка (`fingerprint`) делается так, чтобы все события с одинаковым
31
32
  * `apiKey + errorCode` сливались в один issue. Иначе Sentry плодит отдельный
@@ -25,8 +25,9 @@
25
25
  * skip → не репортим
26
26
  * 304 (etag-success), отменённые axios-запросы (ERR_CANCELED),
27
27
  * 401/403 на app/auth (ожидаемый session/role flow), а также
28
- * обрывы связи на стороне клиента: `Network Error` и
29
- * «timeout of 0ms exceeded» (запрос умер до ответа сервера).
28
+ * обрывы связи на стороне клиента: `Network Error`,
29
+ * «timeout of 0ms exceeded» и «Request aborted» (запрос умер
30
+ * до ответа сервера).
30
31
  *
31
32
  * Группировка (`fingerprint`) делается так, чтобы все события с одинаковым
32
33
  * `apiKey + errorCode` сливались в один issue. Иначе Sentry плодит отдельный
@@ -80,19 +81,32 @@ const SKIP_AUTH_STATUSES_ON_APPS = new Set(['app', 'auth']);
80
81
  */
81
82
  const SKIP_NOT_FOUND_API_KEYS = new Set(['login', 'loginSwitch']);
82
83
  const isNetworkErrorMessage = (message) => typeof message === 'string' && /network error/i.test(message);
84
+ /**
85
+ * Сообщения ECONNABORTED, означающие «запрос отменил браузер/ОС», а не
86
+ * «бэк не ответил за отведённый таймаут». Сконфигурированный таймаут виджета
87
+ * («timeout of 25000ms exceeded») этому не соответствует и остаётся critical.
88
+ */
89
+ const isClientAbortMessage = (message) => typeof message === 'string' && /timeout of 0ms|request aborted/i.test(message);
83
90
  function classifyApiError(ctx) {
84
91
  const { status, code, apiKey, errorCode, originalErrorCode, message, app } = ctx;
85
92
  if (code && SKIP_AXIOS_CODES.has(code)) {
86
93
  return { severity: 'skip', level: 'info', fingerprint: null, shouldReport: false };
87
94
  }
88
- // «timeout of 0ms exceeded» — Safari/iOS обрывает XHR при смене сети или
89
- // уходе вкладки в фон (timeout у запроса не выставлен, обрыв не наш).
95
+ // ECONNABORTED, где запрос умер на клиенте, а не на бэке:
96
+ //
97
+ // «timeout of 0ms exceeded» — Safari/iOS обрывает XHR при смене сети или
98
+ // уходе вкладки в фон (timeout у запроса не выставлен, обрыв не наш).
99
+ // Раньше репортили как business/info «для картины мобильной связности»:
100
+ // за месяц это дало десяток issue на 50+ человек
101
+ // (ESCAPE-NAVIGATOR-APP-3J/3K/3M/3H/2P/3G/33), в которых ни одного бага.
102
+ //
103
+ // «Request aborted» — axios@0.21 отдаёт это на `xhr.onabort`, то есть
104
+ // когда браузер сам убил запрос: пользователь ушёл со страницы, закрыл
105
+ // таб или нажал «стоп» до ответа. О здоровье API это не говорит ничего,
106
+ // а в Sentry попадало как critical.
90
107
  //
91
- // Раньше репортили как business/info «для картины мобильной связности»:
92
- // за месяц это дало десяток issue на 50+ человек
93
- // (ESCAPE-NAVIGATOR-APP-3J/3K/3M/3H/2P/3G/33), в которых ни одного бага.
94
108
  // Картину связности всё равно лучше видно по метрикам, а не по issue.
95
- if (code === 'ECONNABORTED' && typeof message === 'string' && /timeout of 0ms/i.test(message)) {
109
+ if (code === 'ECONNABORTED' && isClientAbortMessage(message)) {
96
110
  return { severity: 'skip', level: 'info', fingerprint: null, shouldReport: false };
97
111
  }
98
112
  if (typeof status === 'number' && SKIP_HTTP_STATUSES.has(status)) {
@@ -56,6 +56,15 @@ describe('classifyApiError', () => {
56
56
  expect(r.severity).toBe('skip');
57
57
  expect(r.shouldReport).toBe(false);
58
58
  });
59
+ it('уход со страницы до ответа (Request aborted) → skip', () => {
60
+ const r = (0, classify_api_error_1.classifyApiError)({
61
+ ...base,
62
+ code: 'ECONNABORTED',
63
+ message: 'Request aborted',
64
+ });
65
+ expect(r.severity).toBe('skip');
66
+ expect(r.shouldReport).toBe(false);
67
+ });
59
68
  it('сработавший таймаут виджета (25s) → НЕ skip, это медленный бэк', () => {
60
69
  const r = (0, classify_api_error_1.classifyApiError)({
61
70
  ...base,
@@ -15,7 +15,7 @@
15
15
  * при конверсии (если ключ не в реестре, токен `{X}` оставляется в
16
16
  * тексте как есть — это безопаснее, чем терять данные).
17
17
  */
18
- export declare const KNOWN_PLACEHOLDERS: readonly ["clientName", "childName", "questroomTitle", "promocode", "discount", "validity", "website", "howToFindLocation", "importantInfo", "prepareInfo", "gameResult", "additionalInfo", "phone", "date", "photos", "servicesList", "address", "profileTitle", "birthday", "currency", "orderDetailsHtml", "bookingManagementLink", "questroomPhoto", "code", "language", "players", "mode", "comment"];
18
+ export declare const KNOWN_PLACEHOLDERS: readonly ["clientName", "childName", "questroomTitle", "promocode", "discount", "validity", "website", "howToFindLocation", "importantInfo", "prepareInfo", "gameResult", "additionalInfo", "phone", "date", "photos", "servicesList", "address", "profileTitle", "birthday", "currency", "orderDetailsHtml", "bookingManagementLink", "bookingDeepLink", "questroomPhoto", "code", "language", "players", "mode", "comment"];
19
19
  export type KnownPlaceholder = (typeof KNOWN_PLACEHOLDERS)[number];
20
20
  export declare const isKnownPlaceholder: (id: string) => id is KnownPlaceholder;
21
21
  /**
@@ -48,6 +48,7 @@ exports.KNOWN_PLACEHOLDERS = [
48
48
  'currency',
49
49
  'orderDetailsHtml',
50
50
  'bookingManagementLink',
51
+ 'bookingDeepLink',
51
52
  'questroomPhoto',
52
53
  'code',
53
54
  'language',
@@ -0,0 +1,6 @@
1
+ import { SlotDiscountTypeEnum } from '@escapenavigator/types/dist/slot/enum/slot-discount-type.enum';
2
+ /**
3
+ * Скидка слота для показа: «15%» для процентной и «500 руб.» для фиксированной.
4
+ * Без знака «минус» — вызывающий сам решает, как её подать.
5
+ */
6
+ export declare const formatSlotDiscount: (discount: number, discountType: SlotDiscountTypeEnum | string | null | undefined, currency: string) => string;
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatSlotDiscount = void 0;
4
+ const format_amount_1 = require("../format-amount");
5
+ const get_slot_discount_amount_1 = require("../get-slot-discount-amount");
6
+ /**
7
+ * Скидка слота для показа: «15%» для процентной и «500 руб.» для фиксированной.
8
+ * Без знака «минус» — вызывающий сам решает, как её подать.
9
+ */
10
+ const formatSlotDiscount = (discount, discountType, currency) => {
11
+ if ((0, get_slot_discount_amount_1.isFixedSlotDiscount)(discountType))
12
+ return (0, format_amount_1.formatAmount)(discount, currency);
13
+ return `${discount / 100}%`;
14
+ };
15
+ exports.formatSlotDiscount = formatSlotDiscount;
@@ -0,0 +1,22 @@
1
+ import { SlotDiscountTypeEnum } from '@escapenavigator/types/dist/slot/enum/slot-discount-type.enum';
2
+ type SlotDiscountType = SlotDiscountTypeEnum | string | null;
3
+ type Params = {
4
+ /** База начисления скидки в минорных единицах (стоимость игры за игроков). */
5
+ price: number;
6
+ /** `slots.discount`: базисные пункты для процента, минорные единицы для фикс. */
7
+ discount?: number | null;
8
+ discountType?: SlotDiscountType;
9
+ /**
10
+ * Процентная скидка исторически округлялась по-разному: `Math.round` в
11
+ * расчёте заказа и `Math.floor` в «цене от» для расписания. Параметр
12
+ * сохраняет это различие, чтобы правки не сдвигали цены на проде.
13
+ */
14
+ rounding?: 'round' | 'floor';
15
+ };
16
+ export declare const isFixedSlotDiscount: (discountType?: SlotDiscountType) => boolean;
17
+ /**
18
+ * Сумма скидки слота в минорных единицах. Никогда не превышает базу, поэтому
19
+ * итог не уходит в минус даже если фиксированная скидка больше цены игры.
20
+ */
21
+ export declare const getSlotDiscountAmount: ({ price, discount, discountType, rounding, }: Params) => number;
22
+ export {};
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getSlotDiscountAmount = exports.isFixedSlotDiscount = void 0;
4
+ const slot_discount_type_enum_1 = require("@escapenavigator/types/dist/slot/enum/slot-discount-type.enum");
5
+ const isFixedSlotDiscount = (discountType) => discountType === slot_discount_type_enum_1.SlotDiscountTypeEnum.FIXED;
6
+ exports.isFixedSlotDiscount = isFixedSlotDiscount;
7
+ /**
8
+ * Сумма скидки слота в минорных единицах. Никогда не превышает базу, поэтому
9
+ * итог не уходит в минус даже если фиксированная скидка больше цены игры.
10
+ */
11
+ const getSlotDiscountAmount = ({ price, discount, discountType, rounding = 'round', }) => {
12
+ const value = discount || 0;
13
+ const base = price > 0 ? price : 0;
14
+ if (value <= 0 || !base)
15
+ return 0;
16
+ if ((0, exports.isFixedSlotDiscount)(discountType))
17
+ return Math.min(value, base);
18
+ const raw = (base * value) / 10000;
19
+ return Math.min(base, rounding === 'floor' ? Math.floor(raw) : Math.round(raw));
20
+ };
21
+ exports.getSlotDiscountAmount = getSlotDiscountAmount;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const slot_discount_type_enum_1 = require("@escapenavigator/types/dist/slot/enum/slot-discount-type.enum");
4
+ const get_slot_discount_amount_1 = require("./get-slot-discount-amount");
5
+ describe('getSlotDiscountAmount', () => {
6
+ it('treats a missing type as percent (слоты до появления поля)', () => {
7
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 10000, discount: 1500 })).toBe(1500);
8
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 10000, discount: 1500, discountType: null })).toBe(1500);
9
+ });
10
+ it('rounds percent by half up and by floor on demand', () => {
11
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 3333, discount: 1500 })).toBe(500);
12
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 3333, discount: 1500, rounding: 'floor' })).toBe(499);
13
+ });
14
+ it('takes a fixed discount as an amount in minor units', () => {
15
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({
16
+ price: 10000,
17
+ discount: 1500,
18
+ discountType: slot_discount_type_enum_1.SlotDiscountTypeEnum.FIXED,
19
+ })).toBe(1500);
20
+ });
21
+ it('never exceeds the base price', () => {
22
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({
23
+ price: 3000,
24
+ discount: 500000,
25
+ discountType: slot_discount_type_enum_1.SlotDiscountTypeEnum.FIXED,
26
+ })).toBe(3000);
27
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 3000, discount: 20000 })).toBe(3000);
28
+ });
29
+ it('returns 0 for empty input', () => {
30
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 0, discount: 1500 })).toBe(0);
31
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: 10000 })).toBe(0);
32
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({ price: -100, discount: 1500 })).toBe(0);
33
+ expect((0, get_slot_discount_amount_1.getSlotDiscountAmount)({
34
+ price: 10000,
35
+ discount: -500,
36
+ discountType: slot_discount_type_enum_1.SlotDiscountTypeEnum.FIXED,
37
+ })).toBe(0);
38
+ });
39
+ });
package/dist/index.d.ts CHANGED
@@ -8,12 +8,14 @@ export * from './convert-to-options';
8
8
  export * from './date';
9
9
  export * from './enum-to-options';
10
10
  export * from './format-flex-duration-label';
11
+ export * from './format-slot-discount';
11
12
  export * from './functional-popover-panel-style';
12
13
  export * from './get-documents-links';
13
14
  export * from './get-full-name';
14
15
  export * from './get-handled-error-message';
15
16
  export * from './get-order-cancelation-state';
16
17
  export * from './get-service-error';
18
+ export * from './get-slot-discount-amount';
17
19
  export * from './google-places-link-warnings';
18
20
  export * from './google-places-nonquest';
19
21
  export * from './image-url';
@@ -29,11 +31,13 @@ export * from './resolve-calendar-location-id';
29
31
  export * from './role-chat-permissions';
30
32
  export * from './role-comment-permissions';
31
33
  export * from './role-export-permissions';
34
+ export * from './role-refund-permissions';
32
35
  export * from './serialize-record';
33
36
  export * from './serialize-slot';
34
37
  export * from './slot-representative-price';
35
38
  export * from './strip-html-tags';
36
39
  export * from './tz-date';
40
+ export * from './upselling-lead-time';
37
41
  export * from './user-session-permissions';
38
42
  export * from './utm-touchpoints';
39
43
  export * from './validate-by-dto';
package/dist/index.js CHANGED
@@ -24,12 +24,14 @@ __exportStar(require("./convert-to-options"), exports);
24
24
  __exportStar(require("./date"), exports);
25
25
  __exportStar(require("./enum-to-options"), exports);
26
26
  __exportStar(require("./format-flex-duration-label"), exports);
27
+ __exportStar(require("./format-slot-discount"), exports);
27
28
  __exportStar(require("./functional-popover-panel-style"), exports);
28
29
  __exportStar(require("./get-documents-links"), exports);
29
30
  __exportStar(require("./get-full-name"), exports);
30
31
  __exportStar(require("./get-handled-error-message"), exports);
31
32
  __exportStar(require("./get-order-cancelation-state"), exports);
32
33
  __exportStar(require("./get-service-error"), exports);
34
+ __exportStar(require("./get-slot-discount-amount"), exports);
33
35
  __exportStar(require("./google-places-link-warnings"), exports);
34
36
  __exportStar(require("./google-places-nonquest"), exports);
35
37
  __exportStar(require("./image-url"), exports);
@@ -45,11 +47,13 @@ __exportStar(require("./resolve-calendar-location-id"), exports);
45
47
  __exportStar(require("./role-chat-permissions"), exports);
46
48
  __exportStar(require("./role-comment-permissions"), exports);
47
49
  __exportStar(require("./role-export-permissions"), exports);
50
+ __exportStar(require("./role-refund-permissions"), exports);
48
51
  __exportStar(require("./serialize-record"), exports);
49
52
  __exportStar(require("./serialize-slot"), exports);
50
53
  __exportStar(require("./slot-representative-price"), exports);
51
54
  __exportStar(require("./strip-html-tags"), exports);
52
55
  __exportStar(require("./tz-date"), exports);
56
+ __exportStar(require("./upselling-lead-time"), exports);
53
57
  __exportStar(require("./user-session-permissions"), exports);
54
58
  __exportStar(require("./utm-touchpoints"), exports);
55
59
  __exportStar(require("./validate-by-dto"), exports);
@@ -0,0 +1,73 @@
1
+ import { PackagePricingModeEnum } from '@escapenavigator/types/dist/packages/package-pricing-mode.enum';
2
+ /**
3
+ * Единый калькулятор цены пакета. Живёт в utils, потому что одна и та же
4
+ * формула нужна в трёх местах: витрина виджета (показать «от»), конструктор
5
+ * (показать итог до брони) и бэкенд (посчитать реальную цену заказа).
6
+ * Расхождение между ними = клиент видит одну сумму, а платит другую.
7
+ */
8
+ /** Поля пакета, от которых зависит цена. Ровно то, что нужно калькулятору. */
9
+ export type PackagePricingInput = {
10
+ pricingMode: PackagePricingModeEnum;
11
+ price: number;
12
+ weekendPrice?: number | null;
13
+ weekendDays?: number[] | null;
14
+ includedPlayers: number;
15
+ extraPlayerPrice: number;
16
+ };
17
+ export type PackagePriceBreakdown = {
18
+ /** Базовая цена пакета на выбранную дату. */
19
+ base: number;
20
+ /** Сколько игроков сверх включённых в пакет. */
21
+ extraPlayers: number;
22
+ /** Доплата за игроков сверх пакета. */
23
+ extraPlayersTotal: number;
24
+ /** Стоимость выбранных платных допов. */
25
+ addonsTotal: number;
26
+ /** Итог: base + extraPlayersTotal + addonsTotal. */
27
+ total: number;
28
+ /** Цена на человека (для «≈ 1 710 ₽ / чел.»). */
29
+ perPerson: number;
30
+ /** Дата попала в «выходные» по настройкам пакета. */
31
+ isWeekend: boolean;
32
+ };
33
+ /**
34
+ * Индекс дня недели в проектной нумерации: 0 — понедельник … 6 — воскресенье.
35
+ * Совпадает с `availableDays` слотов/апселлов и компонентом `DaysOfWeek`,
36
+ * поэтому НЕ равен `Date.getDay()`.
37
+ */
38
+ export declare const getProjectWeekday: (date: Date | string) => number;
39
+ export declare const isPackageWeekend: (pkg: Pick<PackagePricingInput, "weekendDays">, date?: Date | string | null) => boolean;
40
+ /** Цена пакета за день без учёта игроков и допов. */
41
+ export declare const getPackageBasePrice: (pkg: PackagePricingInput, date?: Date | string | null) => number;
42
+ /** Диапазон цен пакета по дням недели — для витрины («от / до»). */
43
+ export declare const getPackagePriceRange: (pkg: PackagePricingInput) => {
44
+ min: number;
45
+ max: number;
46
+ hasWeekendPrice: boolean;
47
+ };
48
+ export declare const getPackageExtraPlayers: (pkg: PackagePricingInput, players: number) => number;
49
+ /**
50
+ * Полный расчёт цены пакета в режиме `fixed`.
51
+ *
52
+ * Для `discount`-пакетов возвращает нули по базе: там цену диктуют тарифы
53
+ * слотов, а пакет умеет только скидку — считать её без состава группы нельзя.
54
+ */
55
+ export declare const calculatePackagePrice: ({ pkg, date, players, addonsTotal, }: {
56
+ pkg: PackagePricingInput;
57
+ date?: Date | string | null;
58
+ players: number;
59
+ addonsTotal?: number;
60
+ }) => PackagePriceBreakdown;
61
+ /**
62
+ * Прайс-мапа `{ игроков: цена }` для заказа-носителя пакета.
63
+ *
64
+ * Заказ хранит снимок тарифа в `order.price`, и `getPlayersPrice` берёт из
65
+ * него значение по текущему числу игроков. Поэтому мы отдаём не одно число,
66
+ * а карту на весь допустимый диапазон: если менеджер потом поменяет состав,
67
+ * пересчёт останется по формуле пакета, а не превратится в ноль.
68
+ */
69
+ export declare const buildPackagePriceMap: ({ pkg, date, maxPlayers, }: {
70
+ pkg: PackagePricingInput;
71
+ date?: Date | string | null;
72
+ maxPlayers: number;
73
+ }) => Record<string, number>;
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildPackagePriceMap = exports.calculatePackagePrice = exports.getPackageExtraPlayers = exports.getPackagePriceRange = exports.getPackageBasePrice = exports.isPackageWeekend = exports.getProjectWeekday = void 0;
4
+ const package_pricing_mode_enum_1 = require("@escapenavigator/types/dist/packages/package-pricing-mode.enum");
5
+ const DEFAULT_WEEKEND_DAYS = [5, 6];
6
+ const toNumber = (value) => {
7
+ const parsed = Number(value);
8
+ return Number.isFinite(parsed) ? parsed : 0;
9
+ };
10
+ /**
11
+ * Индекс дня недели в проектной нумерации: 0 — понедельник … 6 — воскресенье.
12
+ * Совпадает с `availableDays` слотов/апселлов и компонентом `DaysOfWeek`,
13
+ * поэтому НЕ равен `Date.getDay()`.
14
+ */
15
+ const getProjectWeekday = (date) => {
16
+ const parsed = typeof date === 'string' ? new Date(`${date}T00:00:00`) : date;
17
+ if (Number.isNaN(parsed.getTime()))
18
+ return 0;
19
+ return (parsed.getDay() + 6) % 7;
20
+ };
21
+ exports.getProjectWeekday = getProjectWeekday;
22
+ const isPackageWeekend = (pkg, date) => {
23
+ if (!date)
24
+ return false;
25
+ const weekendDays = pkg.weekendDays?.length ? pkg.weekendDays : DEFAULT_WEEKEND_DAYS;
26
+ return weekendDays.includes((0, exports.getProjectWeekday)(date));
27
+ };
28
+ exports.isPackageWeekend = isPackageWeekend;
29
+ /** Цена пакета за день без учёта игроков и допов. */
30
+ const getPackageBasePrice = (pkg, date) => {
31
+ const weekday = Math.max(0, toNumber(pkg.price));
32
+ const weekend = pkg.weekendPrice == null ? weekday : Math.max(0, toNumber(pkg.weekendPrice));
33
+ return (0, exports.isPackageWeekend)(pkg, date) ? weekend : weekday;
34
+ };
35
+ exports.getPackageBasePrice = getPackageBasePrice;
36
+ /** Диапазон цен пакета по дням недели — для витрины («от / до»). */
37
+ const getPackagePriceRange = (pkg) => {
38
+ const weekday = Math.max(0, toNumber(pkg.price));
39
+ const weekend = pkg.weekendPrice == null ? weekday : Math.max(0, toNumber(pkg.weekendPrice));
40
+ return {
41
+ min: Math.min(weekday, weekend),
42
+ max: Math.max(weekday, weekend),
43
+ hasWeekendPrice: pkg.weekendPrice != null && weekend !== weekday,
44
+ };
45
+ };
46
+ exports.getPackagePriceRange = getPackagePriceRange;
47
+ const getPackageExtraPlayers = (pkg, players) => Math.max(0, Math.floor(toNumber(players)) - Math.max(0, Math.floor(toNumber(pkg.includedPlayers))));
48
+ exports.getPackageExtraPlayers = getPackageExtraPlayers;
49
+ /**
50
+ * Полный расчёт цены пакета в режиме `fixed`.
51
+ *
52
+ * Для `discount`-пакетов возвращает нули по базе: там цену диктуют тарифы
53
+ * слотов, а пакет умеет только скидку — считать её без состава группы нельзя.
54
+ */
55
+ const calculatePackagePrice = ({ pkg, date, players, addonsTotal = 0, }) => {
56
+ const isFixed = pkg.pricingMode === package_pricing_mode_enum_1.PackagePricingModeEnum.FIXED;
57
+ const base = isFixed ? (0, exports.getPackageBasePrice)(pkg, date) : 0;
58
+ const extraPlayers = isFixed ? (0, exports.getPackageExtraPlayers)(pkg, players) : 0;
59
+ const extraPlayersTotal = extraPlayers * Math.max(0, toNumber(pkg.extraPlayerPrice));
60
+ const safeAddons = Math.max(0, toNumber(addonsTotal));
61
+ const total = base + extraPlayersTotal + safeAddons;
62
+ const guests = Math.max(1, Math.floor(toNumber(players)));
63
+ return {
64
+ base,
65
+ extraPlayers,
66
+ extraPlayersTotal,
67
+ addonsTotal: safeAddons,
68
+ total,
69
+ perPerson: Math.round(total / guests),
70
+ isWeekend: (0, exports.isPackageWeekend)(pkg, date),
71
+ };
72
+ };
73
+ exports.calculatePackagePrice = calculatePackagePrice;
74
+ /**
75
+ * Прайс-мапа `{ игроков: цена }` для заказа-носителя пакета.
76
+ *
77
+ * Заказ хранит снимок тарифа в `order.price`, и `getPlayersPrice` берёт из
78
+ * него значение по текущему числу игроков. Поэтому мы отдаём не одно число,
79
+ * а карту на весь допустимый диапазон: если менеджер потом поменяет состав,
80
+ * пересчёт останется по формуле пакета, а не превратится в ноль.
81
+ */
82
+ const buildPackagePriceMap = ({ pkg, date, maxPlayers, }) => {
83
+ const base = (0, exports.getPackageBasePrice)(pkg, date);
84
+ const extraPrice = Math.max(0, toNumber(pkg.extraPlayerPrice));
85
+ const limit = Math.max(1, Math.floor(toNumber(maxPlayers)));
86
+ const map = {};
87
+ for (let players = 1; players <= limit; players++) {
88
+ map[players] = base + (0, exports.getPackageExtraPlayers)(pkg, players) * extraPrice;
89
+ }
90
+ return map;
91
+ };
92
+ exports.buildPackagePriceMap = buildPackagePriceMap;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const package_pricing_mode_enum_1 = require("@escapenavigator/types/dist/packages/package-pricing-mode.enum");
4
+ const _1 = require(".");
5
+ /** «Хит» из витрины: 13 590 в будни, 15 590 в выходные, 8 гостей, +800 за гостя. */
6
+ const hit = {
7
+ pricingMode: package_pricing_mode_enum_1.PackagePricingModeEnum.FIXED,
8
+ price: 13590,
9
+ weekendPrice: 15590,
10
+ weekendDays: [5, 6],
11
+ includedPlayers: 8,
12
+ extraPlayerPrice: 800,
13
+ };
14
+ describe('getProjectWeekday', () => {
15
+ it('нумерует с понедельника, а не с воскресенья как Date.getDay', () => {
16
+ expect((0, _1.getProjectWeekday)('2026-08-17')).toBe(0);
17
+ expect((0, _1.getProjectWeekday)('2026-08-22')).toBe(5);
18
+ expect((0, _1.getProjectWeekday)('2026-08-23')).toBe(6);
19
+ });
20
+ });
21
+ describe('isPackageWeekend', () => {
22
+ it('по умолчанию выходные — суббота и воскресенье', () => {
23
+ expect((0, _1.isPackageWeekend)({ weekendDays: [] }, '2026-08-22')).toBe(true);
24
+ expect((0, _1.isPackageWeekend)({ weekendDays: null }, '2026-08-19')).toBe(false);
25
+ });
26
+ it('уважает кастомный набор дней', () => {
27
+ expect((0, _1.isPackageWeekend)({ weekendDays: [4, 5, 6] }, '2026-08-21')).toBe(true);
28
+ });
29
+ it('без даты не угадывает', () => {
30
+ expect((0, _1.isPackageWeekend)(hit, null)).toBe(false);
31
+ });
32
+ });
33
+ describe('getPackageBasePrice', () => {
34
+ it('берёт цену выходного дня только в выходные', () => {
35
+ expect((0, _1.getPackageBasePrice)(hit, '2026-08-19')).toBe(13590);
36
+ expect((0, _1.getPackageBasePrice)(hit, '2026-08-22')).toBe(15590);
37
+ });
38
+ it('без цены выходного дня цена одна на всю неделю', () => {
39
+ expect((0, _1.getPackageBasePrice)({ ...hit, weekendPrice: null }, '2026-08-22')).toBe(13590);
40
+ });
41
+ });
42
+ describe('getPackagePriceRange', () => {
43
+ it('отдаёт диапазон для витрины', () => {
44
+ expect((0, _1.getPackagePriceRange)(hit)).toEqual({
45
+ min: 13590,
46
+ max: 15590,
47
+ hasWeekendPrice: true,
48
+ });
49
+ });
50
+ it('схлопывает диапазон, когда выходная цена совпадает с будней', () => {
51
+ expect((0, _1.getPackagePriceRange)({ ...hit, weekendPrice: 13590 })).toEqual({
52
+ min: 13590,
53
+ max: 13590,
54
+ hasWeekendPrice: false,
55
+ });
56
+ });
57
+ });
58
+ describe('calculatePackagePrice', () => {
59
+ it('считает 9 гостей в будни как пакет + одна доплата', () => {
60
+ expect((0, _1.calculatePackagePrice)({ pkg: hit, date: '2026-08-19', players: 9, addonsTotal: 1000 })).toEqual({
61
+ base: 13590,
62
+ extraPlayers: 1,
63
+ extraPlayersTotal: 800,
64
+ addonsTotal: 1000,
65
+ total: 15390,
66
+ perPerson: 1710,
67
+ isWeekend: false,
68
+ });
69
+ });
70
+ it('не берёт доплату, пока гостей не больше включённых', () => {
71
+ const result = (0, _1.calculatePackagePrice)({ pkg: hit, date: '2026-08-19', players: 5 });
72
+ expect(result.extraPlayers).toBe(0);
73
+ expect(result.total).toBe(13590);
74
+ });
75
+ it('в discount-режиме базы у пакета нет — цену диктуют тарифы слотов', () => {
76
+ const result = (0, _1.calculatePackagePrice)({
77
+ pkg: { ...hit, pricingMode: package_pricing_mode_enum_1.PackagePricingModeEnum.DISCOUNT },
78
+ date: '2026-08-22',
79
+ players: 12,
80
+ addonsTotal: 500,
81
+ });
82
+ expect(result.base).toBe(0);
83
+ expect(result.extraPlayersTotal).toBe(0);
84
+ expect(result.total).toBe(500);
85
+ });
86
+ });
87
+ describe('buildPackagePriceMap', () => {
88
+ it('покрывает весь диапазон, чтобы смена состава не обнуляла заказ', () => {
89
+ const map = (0, _1.buildPackagePriceMap)({ pkg: hit, date: '2026-08-19', maxPlayers: 10 });
90
+ expect(map[1]).toBe(13590);
91
+ expect(map[8]).toBe(13590);
92
+ expect(map[9]).toBe(14390);
93
+ expect(map[10]).toBe(15190);
94
+ expect(Object.keys(map)).toHaveLength(10);
95
+ });
96
+ it('в выходные строится от выходной цены', () => {
97
+ const map = (0, _1.buildPackagePriceMap)({ pkg: hit, date: '2026-08-22', maxPlayers: 9 });
98
+ expect(map[9]).toBe(16390);
99
+ });
100
+ });
@@ -0,0 +1,7 @@
1
+ import { RoleRO } from '@escapenavigator/types/dist/role/role.ro';
2
+ export type RoleRefundPermissions = Pick<RoleRO, 'totalAccess' | 'canAddRefunds'>;
3
+ /**
4
+ * Право оформлять возвраты (в т.ч. Stripe). Владелец (`totalAccess`) —
5
+ * как будто флаг включён.
6
+ */
7
+ export declare const canAddRefunds: (role?: RoleRefundPermissions | null) => boolean;
@@ -0,0 +1,9 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.canAddRefunds = void 0;
4
+ /**
5
+ * Право оформлять возвраты (в т.ч. Stripe). Владелец (`totalAccess`) —
6
+ * как будто флаг включён.
7
+ */
8
+ const canAddRefunds = (role) => !!role?.totalAccess || !!role?.canAddRefunds;
9
+ exports.canAddRefunds = canAddRefunds;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const role_refund_permissions_1 = require("./role-refund-permissions");
4
+ describe('canAddRefunds', () => {
5
+ it('is false without role or flag', () => {
6
+ expect((0, role_refund_permissions_1.canAddRefunds)()).toBe(false);
7
+ expect((0, role_refund_permissions_1.canAddRefunds)(null)).toBe(false);
8
+ expect((0, role_refund_permissions_1.canAddRefunds)({ totalAccess: false, canAddRefunds: false })).toBe(false);
9
+ });
10
+ it('is true when flag is on', () => {
11
+ expect((0, role_refund_permissions_1.canAddRefunds)({ totalAccess: false, canAddRefunds: true })).toBe(true);
12
+ });
13
+ it('is true for totalAccess even when flag is off', () => {
14
+ expect((0, role_refund_permissions_1.canAddRefunds)({ totalAccess: true, canAddRefunds: false })).toBe(true);
15
+ });
16
+ });
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.serializeSlot = exports.serializeSlotOrderData = void 0;
4
+ const slot_discount_type_enum_1 = require("@escapenavigator/types/dist/slot/enum/slot-discount-type.enum");
4
5
  const defailt_rule_1 = require("@escapenavigator/types/dist/slot-rule/defailt-rule");
5
6
  const buildRuleSummary = (rule) => {
6
7
  const { prepayment, prepaymentType, title, minHoursForBooking, minMinutesForBooking, minHoursForFreeCanceling, cancelationRule, minHoursForFullFine, cancelationAmount, } = rule || defailt_rule_1.defaultRule;
@@ -62,9 +63,14 @@ const serializeSlot = ({ slot, orders = [] }) => ({
62
63
  tariff: slot.tariff?.price || slot.tariff || {},
63
64
  status: slot.status,
64
65
  discount: slot.discount ?? 0,
66
+ discountType: slot.discountType ?? slot_discount_type_enum_1.SlotDiscountTypeEnum.PERCENT,
65
67
  onlyPhone: slot.onlyPhone ?? false,
66
68
  forceOnlineBooking: slot.forceOnlineBooking ?? false,
67
69
  ignoreResourceLimit: slot.ignoreResourceLimit ?? false,
70
+ // Пустой список не отдаём вообще: `calendar-day` сериализует до 1000
71
+ // слотов за раз, и `"excludedUpsellingIds":[]` на каждом — это лишние
72
+ // десятки килобайт в ответе ради информации «ничего не запрещено».
73
+ excludedUpsellingIds: slot.excludedUpsellingIds?.length ? slot.excludedUpsellingIds : undefined,
68
74
  breakReason: slot.breakReason ?? null,
69
75
  availableTeams: slot.availableTeams ?? null,
70
76
  numSeatsAvailable: slot.numSeatsAvailable ?? slot.availablePlayers ?? 0,
@@ -1,7 +1,9 @@
1
+ import { SlotDiscountTypeEnum } from '@escapenavigator/types/dist/slot/enum/slot-discount-type.enum';
1
2
  type SlotPriceSource = {
2
3
  tariff?: unknown;
3
4
  basePrice?: number;
4
5
  discount?: number;
6
+ discountType?: SlotDiscountTypeEnum | string | null;
5
7
  };
6
8
  /**
7
9
  * Плоская карта цен из тарифа слота.
@@ -10,6 +12,14 @@ type SlotPriceSource = {
10
12
  export declare const getTariffPriceMap: (tariff: unknown) => Record<string, number>;
11
13
  /** Минимальная положительная цена из тарифа (без скидки слота). */
12
14
  export declare const getTariffBasePrice: (tariff: unknown) => number | null;
15
+ /**
16
+ * Цена «от» до и после скидки слота. Обе величины нужны расписанию: одну
17
+ * показываем, вторую зачёркиваем.
18
+ */
19
+ export declare const getSlotRepresentativePrices: (slot: SlotPriceSource) => {
20
+ base: number;
21
+ discounted: number;
22
+ } | null;
13
23
  /** Цена «от» для слота в расписании (с учётом скидки слота). */
14
24
  export declare const getSlotRepresentativePrice: (slot: SlotPriceSource) => number | null;
15
25
  export {};
@@ -1,12 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getSlotRepresentativePrice = exports.getTariffBasePrice = exports.getTariffPriceMap = 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
- };
3
+ exports.getSlotRepresentativePrice = exports.getSlotRepresentativePrices = exports.getTariffBasePrice = exports.getTariffPriceMap = void 0;
4
+ const get_slot_discount_amount_1 = require("./get-slot-discount-amount");
10
5
  /**
11
6
  * Плоская карта цен из тарифа слота.
12
7
  * Принимает и `{ id, price: { "2": 1500 } }` (листинг), и `{ "2": 1500 }` (/slots/details).
@@ -36,13 +31,25 @@ const getTariffBasePrice = (tariff) => {
36
31
  return null;
37
32
  };
38
33
  exports.getTariffBasePrice = getTariffBasePrice;
39
- /** Цена «от» для слота в расписании (с учётом скидки слота). */
40
- const getSlotRepresentativePrice = (slot) => {
34
+ /**
35
+ * Цена «от» до и после скидки слота. Обе величины нужны расписанию: одну
36
+ * показываем, вторую зачёркиваем.
37
+ */
38
+ const getSlotRepresentativePrices = (slot) => {
41
39
  const base = typeof slot.basePrice === 'number' && slot.basePrice > 0
42
40
  ? slot.basePrice
43
41
  : (0, exports.getTariffBasePrice)(slot.tariff);
44
42
  if (base == null)
45
43
  return null;
46
- return applySlotDiscount(base, slot.discount);
44
+ const discountAmount = (0, get_slot_discount_amount_1.getSlotDiscountAmount)({
45
+ price: base,
46
+ discount: slot.discount,
47
+ discountType: slot.discountType,
48
+ rounding: 'floor',
49
+ });
50
+ return { base, discounted: base - discountAmount };
47
51
  };
52
+ exports.getSlotRepresentativePrices = getSlotRepresentativePrices;
53
+ /** Цена «от» для слота в расписании (с учётом скидки слота). */
54
+ const getSlotRepresentativePrice = (slot) => (0, exports.getSlotRepresentativePrices)(slot)?.discounted ?? null;
48
55
  exports.getSlotRepresentativePrice = getSlotRepresentativePrice;
@@ -0,0 +1,36 @@
1
+ type Props = {
2
+ /** `upsellings.minMinutesBeforeStart`: `0`/пусто — услуга без ограничения. */
3
+ minMinutesBeforeStart?: number | null;
4
+ /** Дата слота `yyyy-MM-dd`. */
5
+ date: string;
6
+ /** Начало слота `HH:mm`. */
7
+ start: string;
8
+ /** Таймзона локации квеста, а не браузера клиента. */
9
+ timeZone?: string | null;
10
+ /** Точка отсчёта «сейчас»; параметр ради тестов. */
11
+ now?: Date;
12
+ };
13
+ /**
14
+ * Успевает ли клиент заказать доп. услугу онлайн: у услуг с подготовкой
15
+ * (актёр, торт) венью выставляет запас времени, за который персонал ещё
16
+ * можно найти.
17
+ *
18
+ * Момент старта слота считается строго по таймзоне локации квеста — иначе
19
+ * клиент из другого часового пояса увидел бы другой набор услуг. Ровно на
20
+ * границе (осталось точно `minMinutesBeforeStart`) услуга ещё доступна.
21
+ *
22
+ * Как и `applyTimingStatus` в slot.service, при неизвестной таймзоне ничего
23
+ * не запрещаем: тихо потерять услугу из-за orphan-локации хуже, чем отдать
24
+ * её на пару часов дольше положенного.
25
+ */
26
+ export declare const isUpsellingLeadTimeSatisfied: ({ minMinutesBeforeStart, date, start, timeZone, now, }: Props) => boolean;
27
+ /**
28
+ * То же правило для вызовов, у которых момент старта игры уже посчитан в UTC
29
+ * (`orders.utcDate`), — тогда таймзона локации не нужна.
30
+ */
31
+ export declare const isUpsellingLeadTimeSatisfiedAt: ({ minMinutesBeforeStart, slotStartUtc, now, }: {
32
+ minMinutesBeforeStart?: number | null;
33
+ slotStartUtc: Date | string;
34
+ now?: Date;
35
+ }) => boolean;
36
+ export {};
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isUpsellingLeadTimeSatisfiedAt = exports.isUpsellingLeadTimeSatisfied = void 0;
4
+ const date_timezone_1 = require("../date-timezone");
5
+ const MINUTE_IN_MS = 60 * 1000;
6
+ /**
7
+ * Успевает ли клиент заказать доп. услугу онлайн: у услуг с подготовкой
8
+ * (актёр, торт) венью выставляет запас времени, за который персонал ещё
9
+ * можно найти.
10
+ *
11
+ * Момент старта слота считается строго по таймзоне локации квеста — иначе
12
+ * клиент из другого часового пояса увидел бы другой набор услуг. Ровно на
13
+ * границе (осталось точно `minMinutesBeforeStart`) услуга ещё доступна.
14
+ *
15
+ * Как и `applyTimingStatus` в slot.service, при неизвестной таймзоне ничего
16
+ * не запрещаем: тихо потерять услугу из-за orphan-локации хуже, чем отдать
17
+ * её на пару часов дольше положенного.
18
+ */
19
+ const isUpsellingLeadTimeSatisfied = ({ minMinutesBeforeStart, date, start, timeZone, now = new Date(), }) => {
20
+ if (!minMinutesBeforeStart || minMinutesBeforeStart <= 0)
21
+ return true;
22
+ if (!timeZone)
23
+ return true;
24
+ // Локальные компоненты даты сохраняются при переводе в UTC ниже; проверяем
25
+ // до конвертации, потому что на невалидной дате она бросает, а фильтр
26
+ // услуг вызывается на рендере виджета.
27
+ const wallClock = new Date(`${date}T${start}`);
28
+ if (Number.isNaN(wallClock.getTime()))
29
+ return true;
30
+ return (0, exports.isUpsellingLeadTimeSatisfiedAt)({
31
+ minMinutesBeforeStart,
32
+ slotStartUtc: (0, date_timezone_1.zonedTimeToUtc)(wallClock, timeZone),
33
+ now,
34
+ });
35
+ };
36
+ exports.isUpsellingLeadTimeSatisfied = isUpsellingLeadTimeSatisfied;
37
+ /**
38
+ * То же правило для вызовов, у которых момент старта игры уже посчитан в UTC
39
+ * (`orders.utcDate`), — тогда таймзона локации не нужна.
40
+ */
41
+ const isUpsellingLeadTimeSatisfiedAt = ({ minMinutesBeforeStart, slotStartUtc, now = new Date(), }) => {
42
+ if (!minMinutesBeforeStart || minMinutesBeforeStart <= 0)
43
+ return true;
44
+ const slotStartUtcMs = new Date(slotStartUtc).getTime();
45
+ if (!Number.isFinite(slotStartUtcMs))
46
+ return true;
47
+ return now.getTime() <= slotStartUtcMs - minMinutesBeforeStart * MINUTE_IN_MS;
48
+ };
49
+ exports.isUpsellingLeadTimeSatisfiedAt = isUpsellingLeadTimeSatisfiedAt;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const index_1 = require("./index");
4
+ // Игра 20 августа в 18:00 по Москве = 15:00 UTC.
5
+ const slot = { date: '2026-08-20', start: '18:00', timeZone: 'Europe/Moscow' };
6
+ const at = (iso) => new Date(iso);
7
+ describe('isUpsellingLeadTimeSatisfied', () => {
8
+ it('услуга без запаса времени доступна всегда', () => {
9
+ expect((0, index_1.isUpsellingLeadTimeSatisfied)({
10
+ ...slot,
11
+ minMinutesBeforeStart: 0,
12
+ now: at('2026-08-20T14:59:00Z'),
13
+ })).toBe(true);
14
+ expect((0, index_1.isUpsellingLeadTimeSatisfied)({ ...slot, now: at('2026-08-20T14:59:00Z') })).toBe(true);
15
+ });
16
+ it('за 48 часов до игры услуга ещё доступна', () => {
17
+ expect((0, index_1.isUpsellingLeadTimeSatisfied)({
18
+ ...slot,
19
+ minMinutesBeforeStart: 48 * 60,
20
+ now: at('2026-08-18T10:00:00Z'),
21
+ })).toBe(true);
22
+ });
23
+ it('ровно на границе услуга ещё доступна, минутой позже — нет', () => {
24
+ expect((0, index_1.isUpsellingLeadTimeSatisfied)({
25
+ ...slot,
26
+ minMinutesBeforeStart: 48 * 60,
27
+ now: at('2026-08-18T15:00:00Z'),
28
+ })).toBe(true);
29
+ expect((0, index_1.isUpsellingLeadTimeSatisfied)({
30
+ ...slot,
31
+ minMinutesBeforeStart: 48 * 60,
32
+ now: at('2026-08-18T15:01:00Z'),
33
+ })).toBe(false);
34
+ });
35
+ it('считает от таймзоны локации, а не от времени клиента', () => {
36
+ // Клиент в Магадане (UTC+11) видит на часах 02:00 21 августа, но до
37
+ // московской игры остаётся меньше 48 часов — услуга недоступна.
38
+ expect((0, index_1.isUpsellingLeadTimeSatisfied)({
39
+ ...slot,
40
+ timeZone: 'Asia/Magadan',
41
+ minMinutesBeforeStart: 48 * 60,
42
+ now: at('2026-08-18T15:00:00Z'),
43
+ })).toBe(false);
44
+ });
45
+ it('при неизвестной таймзоне ничего не запрещает', () => {
46
+ expect((0, index_1.isUpsellingLeadTimeSatisfied)({
47
+ ...slot,
48
+ timeZone: undefined,
49
+ minMinutesBeforeStart: 48 * 60,
50
+ now: at('2026-08-20T14:00:00Z'),
51
+ })).toBe(true);
52
+ });
53
+ it('битую дату слота не превращает в запрет', () => {
54
+ expect((0, index_1.isUpsellingLeadTimeSatisfied)({
55
+ ...slot,
56
+ date: 'не дата',
57
+ minMinutesBeforeStart: 48 * 60,
58
+ now: at('2026-08-20T14:00:00Z'),
59
+ })).toBe(true);
60
+ });
61
+ });
62
+ describe('isUpsellingLeadTimeSatisfiedAt', () => {
63
+ const slotStartUtc = '2026-08-20T15:00:00.000Z';
64
+ it('сравнивает с уже посчитанным UTC-моментом старта', () => {
65
+ expect((0, index_1.isUpsellingLeadTimeSatisfiedAt)({
66
+ minMinutesBeforeStart: 120,
67
+ slotStartUtc,
68
+ now: at('2026-08-20T13:00:00Z'),
69
+ })).toBe(true);
70
+ expect((0, index_1.isUpsellingLeadTimeSatisfiedAt)({
71
+ minMinutesBeforeStart: 120,
72
+ slotStartUtc,
73
+ now: at('2026-08-20T13:00:01Z'),
74
+ })).toBe(false);
75
+ });
76
+ it('после старта игры услуга с запасом времени недоступна', () => {
77
+ expect((0, index_1.isUpsellingLeadTimeSatisfiedAt)({
78
+ minMinutesBeforeStart: 30,
79
+ slotStartUtc,
80
+ now: at('2026-08-21T00:00:00Z'),
81
+ })).toBe(false);
82
+ });
83
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escapenavigator/utils",
3
- "version": "2.0.36",
3
+ "version": "2.0.40",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -14,7 +14,7 @@
14
14
  "test": "jest"
15
15
  },
16
16
  "dependencies": {
17
- "@escapenavigator/types": "^2.0.34",
17
+ "@escapenavigator/types": "^2.0.38",
18
18
  "axios": "^0.21.4",
19
19
  "class-transformer": "^0.5.1",
20
20
  "class-validator": "^0.13.2",
@@ -29,5 +29,5 @@
29
29
  "ts-jest": "^29.1.1",
30
30
  "typescript": "^5.6"
31
31
  },
32
- "gitHead": "3112d9d954979e406e77a787d459e16c49da3d5a"
32
+ "gitHead": "46c94f5f9d49d1c7efb30798d44bc5ed6e2dcc50"
33
33
  }