@escapenavigator/utils 2.0.39 → 2.0.41

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.
@@ -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
  });
@@ -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',
package/dist/index.d.ts CHANGED
@@ -37,6 +37,7 @@ export * from './serialize-slot';
37
37
  export * from './slot-representative-price';
38
38
  export * from './strip-html-tags';
39
39
  export * from './tz-date';
40
+ export * from './upselling-lead-time';
40
41
  export * from './user-session-permissions';
41
42
  export * from './utm-touchpoints';
42
43
  export * from './validate-by-dto';
package/dist/index.js CHANGED
@@ -53,6 +53,7 @@ __exportStar(require("./serialize-slot"), exports);
53
53
  __exportStar(require("./slot-representative-price"), exports);
54
54
  __exportStar(require("./strip-html-tags"), exports);
55
55
  __exportStar(require("./tz-date"), exports);
56
+ __exportStar(require("./upselling-lead-time"), exports);
56
57
  __exportStar(require("./user-session-permissions"), exports);
57
58
  __exportStar(require("./utm-touchpoints"), exports);
58
59
  __exportStar(require("./validate-by-dto"), exports);
@@ -70,9 +70,7 @@ const serializeSlot = ({ slot, orders = [] }) => ({
70
70
  // Пустой список не отдаём вообще: `calendar-day` сериализует до 1000
71
71
  // слотов за раз, и `"excludedUpsellingIds":[]` на каждом — это лишние
72
72
  // десятки килобайт в ответе ради информации «ничего не запрещено».
73
- excludedUpsellingIds: slot.excludedUpsellingIds?.length
74
- ? slot.excludedUpsellingIds
75
- : undefined,
73
+ excludedUpsellingIds: slot.excludedUpsellingIds?.length ? slot.excludedUpsellingIds : undefined,
76
74
  breakReason: slot.breakReason ?? null,
77
75
  availableTeams: slot.availableTeams ?? null,
78
76
  numSeatsAvailable: slot.numSeatsAvailable ?? slot.availablePlayers ?? 0,
@@ -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.39",
3
+ "version": "2.0.41",
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.37",
17
+ "@escapenavigator/types": "^2.0.39",
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": "a5a2b7072d29242e6477d13cf07022fca9862b8c"
32
+ "gitHead": "e5b9a5cb3f13b9e80c896a40ac0cdde88ff9b7b0"
33
33
  }