@escapenavigator/utils 2.0.15 → 2.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/city-quest-content.d.ts +8 -0
- package/dist/city-quest-content.js +35 -0
- package/dist/format-promocode-discount/index.js +4 -0
- package/dist/get-promocode-discount.d.ts +11 -1
- package/dist/get-promocode-discount.js +10 -1
- package/dist/get-promocode-discount.spec.d.ts +1 -0
- package/dist/get-promocode-discount.spec.js +92 -0
- package/dist/google-places-nonquest.d.ts +19 -0
- package/dist/google-places-nonquest.js +211 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/route-warnings.d.ts +30 -0
- package/dist/route-warnings.js +117 -0
- package/dist/validate-promocode.js +8 -1
- package/package.json +3 -3
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CityQuestContent, CityQuestRouteEvent, CityQuestStop, LocalizedText } from '@escapenavigator/types/dist/city-quest/city-quest-content';
|
|
2
|
+
/** Patch one stop by id; returns new content reference for jsonb saves. */
|
|
3
|
+
export declare const patchQuestStop: (content: CityQuestContent, stopId: string, patch: Partial<CityQuestStop>) => CityQuestContent;
|
|
4
|
+
/** Patch one route event by id. */
|
|
5
|
+
export declare const patchQuestRouteEvent: (content: CityQuestContent, eventId: string, patch: Partial<CityQuestRouteEvent>) => CityQuestContent;
|
|
6
|
+
/** Add a simple message route event after a stop. */
|
|
7
|
+
export declare const addMessageRouteEvent: (content: CityQuestContent, triggerAfterStopId: string, text: LocalizedText, from?: string) => CityQuestContent;
|
|
8
|
+
export declare const removeQuestRouteEvent: (content: CityQuestContent, eventId: string) => CityQuestContent;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.removeQuestRouteEvent = exports.addMessageRouteEvent = exports.patchQuestRouteEvent = exports.patchQuestStop = void 0;
|
|
4
|
+
/** Patch one stop by id; returns new content reference for jsonb saves. */
|
|
5
|
+
const patchQuestStop = (content, stopId, patch) => ({
|
|
6
|
+
...content,
|
|
7
|
+
stops: (content.stops || []).map((stop) => (stop.id === stopId ? { ...stop, ...patch } : stop)),
|
|
8
|
+
});
|
|
9
|
+
exports.patchQuestStop = patchQuestStop;
|
|
10
|
+
/** Patch one route event by id. */
|
|
11
|
+
const patchQuestRouteEvent = (content, eventId, patch) => ({
|
|
12
|
+
...content,
|
|
13
|
+
route_events: (content.route_events || []).map((event) => event.id === eventId ? { ...event, ...patch } : event),
|
|
14
|
+
});
|
|
15
|
+
exports.patchQuestRouteEvent = patchQuestRouteEvent;
|
|
16
|
+
/** Add a simple message route event after a stop. */
|
|
17
|
+
const addMessageRouteEvent = (content, triggerAfterStopId, text, from) => {
|
|
18
|
+
const id = `re_${Date.now().toString(36)}`;
|
|
19
|
+
const event = {
|
|
20
|
+
id,
|
|
21
|
+
trigger_after_stop: triggerAfterStopId,
|
|
22
|
+
type: 'message',
|
|
23
|
+
content: { from, text },
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
...content,
|
|
27
|
+
route_events: [...(content.route_events || []), event],
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
exports.addMessageRouteEvent = addMessageRouteEvent;
|
|
31
|
+
const removeQuestRouteEvent = (content, eventId) => ({
|
|
32
|
+
...content,
|
|
33
|
+
route_events: (content.route_events || []).filter((e) => e.id !== eventId),
|
|
34
|
+
});
|
|
35
|
+
exports.removeQuestRouteEvent = removeQuestRouteEvent;
|
|
@@ -15,6 +15,10 @@ const formatPromocodeDiscount = (type, discount, currency, clientDescription) =>
|
|
|
15
15
|
if (type === promocode_type_enum_1.PromocodeTypeEnum.FIXED) {
|
|
16
16
|
return (0, format_amount_1.formatAmount)(discount, currency);
|
|
17
17
|
}
|
|
18
|
+
// Флеш цена: discount — итоговая цена за игроков, показываем «= 99,99 €».
|
|
19
|
+
if (type === promocode_type_enum_1.PromocodeTypeEnum.FLASH_PRICE) {
|
|
20
|
+
return `= ${(0, format_amount_1.formatAmount)(discount, currency)}`;
|
|
21
|
+
}
|
|
18
22
|
return `${discount / 100}%`;
|
|
19
23
|
};
|
|
20
24
|
exports.formatPromocodeDiscount = formatPromocodeDiscount;
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { PromocodeTypeEnum } from '@escapenavigator/types/dist/promocode/emun/promocode-type.enum';
|
|
2
|
-
export declare const getPromocodeDiscount: ({ slotId, slotDiscount, total, promocodeType, promocodeDiscount, price, players, children, }: {
|
|
2
|
+
export declare const getPromocodeDiscount: ({ slotId, slotDiscount, slotDiscountAmount, total, promocodeType, promocodeDiscount, price, players, children, }: {
|
|
3
3
|
slotId: number;
|
|
4
4
|
children?: number;
|
|
5
5
|
total: number;
|
|
6
6
|
players: number;
|
|
7
|
+
/**
|
|
8
|
+
* Легаси-параметр: часть вызовов передаёт сумму, часть — ставку в bps.
|
|
9
|
+
* Используется только процентными типами (вычитание из total).
|
|
10
|
+
*/
|
|
7
11
|
slotDiscount: number;
|
|
12
|
+
/**
|
|
13
|
+
* Скидка слота строго как СУММА в минорных единицах. Нужна только для
|
|
14
|
+
* FLASH_PRICE — флеш-цена поглощает слот-скидку, поэтому вычитаем её из
|
|
15
|
+
* базовой цены за игроков, чтобы итог за игроков был ровно равен флеш-цене.
|
|
16
|
+
*/
|
|
17
|
+
slotDiscountAmount?: number;
|
|
8
18
|
price: Record<any, any>;
|
|
9
19
|
promocodeDiscount: number;
|
|
10
20
|
promocodeType: PromocodeTypeEnum;
|
|
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.getPromocodeDiscount = void 0;
|
|
4
4
|
const promocode_type_enum_1 = require("@escapenavigator/types/dist/promocode/emun/promocode-type.enum");
|
|
5
5
|
const get_players_price_1 = require("./get-players-price");
|
|
6
|
-
const getPromocodeDiscount = ({ slotId, slotDiscount, total, promocodeType, promocodeDiscount, price, players, children = 0, }) => {
|
|
6
|
+
const getPromocodeDiscount = ({ slotId, slotDiscount, slotDiscountAmount = 0, total, promocodeType, promocodeDiscount, price, players, children = 0, }) => {
|
|
7
7
|
const orderTotal = slotId ? total - slotDiscount : total;
|
|
8
8
|
let amount = 0;
|
|
9
9
|
if (promocodeType === promocode_type_enum_1.PromocodeTypeEnum.PROMOTIONAL)
|
|
@@ -14,6 +14,15 @@ const getPromocodeDiscount = ({ slotId, slotDiscount, total, promocodeType, prom
|
|
|
14
14
|
amount = Math.floor(((0, get_players_price_1.getPlayersPrice)({ price, children, players }) / 10000) * promocodeDiscount);
|
|
15
15
|
if (promocodeType === promocode_type_enum_1.PromocodeTypeEnum.PERCENT_ALL_SUM)
|
|
16
16
|
amount = Math.floor((orderTotal / 10000) * promocodeDiscount);
|
|
17
|
+
if (promocodeType === promocode_type_enum_1.PromocodeTypeEnum.FLASH_PRICE) {
|
|
18
|
+
// «Флеш цена»: promocodeDiscount хранит итоговую цену за игроков.
|
|
19
|
+
// Скидка = базовая цена за игроков (за вычетом слот-скидки) − флеш-цена.
|
|
20
|
+
// Если базовая цена уже ниже флеш-цены — скидки нет (не удорожаем).
|
|
21
|
+
// Доп. услуги и наценка на оплату в формуле не участвуют — идут сверху.
|
|
22
|
+
const playersPrice = (0, get_players_price_1.getPlayersPrice)({ price, children, players });
|
|
23
|
+
const playersPriceAfterSlot = playersPrice - (slotId ? slotDiscountAmount : 0);
|
|
24
|
+
amount = Math.max(0, playersPriceAfterSlot - promocodeDiscount);
|
|
25
|
+
}
|
|
17
26
|
return amount;
|
|
18
27
|
};
|
|
19
28
|
exports.getPromocodeDiscount = getPromocodeDiscount;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const promocode_type_enum_1 = require("@escapenavigator/types/dist/promocode/emun/promocode-type.enum");
|
|
4
|
+
const get_promocode_discount_1 = require("./get-promocode-discount");
|
|
5
|
+
// Тариф: 2 игрока — 100.00, 4 игрока — 180.00, ребёнок — 20.00 (minor units).
|
|
6
|
+
const price = { 2: 10000, 4: 18000, child: 2000 };
|
|
7
|
+
const base = {
|
|
8
|
+
slotId: 1,
|
|
9
|
+
slotDiscount: 0,
|
|
10
|
+
slotDiscountAmount: 0,
|
|
11
|
+
total: 10000,
|
|
12
|
+
players: 2,
|
|
13
|
+
children: 0,
|
|
14
|
+
price,
|
|
15
|
+
};
|
|
16
|
+
describe('getPromocodeDiscount / FLASH_PRICE', () => {
|
|
17
|
+
it('скидка = цена за игроков − флеш-цена', () => {
|
|
18
|
+
// 2 игрока = 100.00, флеш 79.99 → скидка 20.01
|
|
19
|
+
const amount = (0, get_promocode_discount_1.getPromocodeDiscount)({
|
|
20
|
+
...base,
|
|
21
|
+
promocodeType: promocode_type_enum_1.PromocodeTypeEnum.FLASH_PRICE,
|
|
22
|
+
promocodeDiscount: 7999,
|
|
23
|
+
});
|
|
24
|
+
expect(amount).toBe(2001);
|
|
25
|
+
});
|
|
26
|
+
it('пересчитывается от кол-ва игроков (доп. услуги не участвуют)', () => {
|
|
27
|
+
// 4 игрока = 180.00; total включает допы, но не влияет на флеш
|
|
28
|
+
const amount = (0, get_promocode_discount_1.getPromocodeDiscount)({
|
|
29
|
+
...base,
|
|
30
|
+
players: 4,
|
|
31
|
+
total: 25000,
|
|
32
|
+
promocodeType: promocode_type_enum_1.PromocodeTypeEnum.FLASH_PRICE,
|
|
33
|
+
promocodeDiscount: 7999,
|
|
34
|
+
});
|
|
35
|
+
expect(amount).toBe(18000 - 7999);
|
|
36
|
+
});
|
|
37
|
+
it('учитывает детей в базовой цене', () => {
|
|
38
|
+
// 2 взрослых + 1 ребёнок = 120.00, флеш 99.99 → скидка 20.01
|
|
39
|
+
const amount = (0, get_promocode_discount_1.getPromocodeDiscount)({
|
|
40
|
+
...base,
|
|
41
|
+
children: 1,
|
|
42
|
+
promocodeType: promocode_type_enum_1.PromocodeTypeEnum.FLASH_PRICE,
|
|
43
|
+
promocodeDiscount: 9999,
|
|
44
|
+
});
|
|
45
|
+
expect(amount).toBe(2001);
|
|
46
|
+
});
|
|
47
|
+
it('поглощает слот-скидку: итог за игроков ровно = флеш-цене', () => {
|
|
48
|
+
// 100.00 − слот-скидка 10.00 = 90.00, флеш 79.99 → скидка 10.01
|
|
49
|
+
const amount = (0, get_promocode_discount_1.getPromocodeDiscount)({
|
|
50
|
+
...base,
|
|
51
|
+
slotDiscountAmount: 1000,
|
|
52
|
+
promocodeType: promocode_type_enum_1.PromocodeTypeEnum.FLASH_PRICE,
|
|
53
|
+
promocodeDiscount: 7999,
|
|
54
|
+
});
|
|
55
|
+
expect(amount).toBe(1001);
|
|
56
|
+
});
|
|
57
|
+
it('не удорожает заказ: если базовая цена ниже флеш-цены — скидка 0', () => {
|
|
58
|
+
const amount = (0, get_promocode_discount_1.getPromocodeDiscount)({
|
|
59
|
+
...base,
|
|
60
|
+
promocodeType: promocode_type_enum_1.PromocodeTypeEnum.FLASH_PRICE,
|
|
61
|
+
promocodeDiscount: 12000,
|
|
62
|
+
});
|
|
63
|
+
expect(amount).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
it('без слота слот-скидка не вычитается', () => {
|
|
66
|
+
const amount = (0, get_promocode_discount_1.getPromocodeDiscount)({
|
|
67
|
+
...base,
|
|
68
|
+
slotId: 0,
|
|
69
|
+
slotDiscountAmount: 1000,
|
|
70
|
+
promocodeType: promocode_type_enum_1.PromocodeTypeEnum.FLASH_PRICE,
|
|
71
|
+
promocodeDiscount: 7999,
|
|
72
|
+
});
|
|
73
|
+
expect(amount).toBe(2001);
|
|
74
|
+
});
|
|
75
|
+
it('не ломает существующие типы', () => {
|
|
76
|
+
expect((0, get_promocode_discount_1.getPromocodeDiscount)({
|
|
77
|
+
...base,
|
|
78
|
+
promocodeType: promocode_type_enum_1.PromocodeTypeEnum.FIXED,
|
|
79
|
+
promocodeDiscount: 500,
|
|
80
|
+
})).toBe(500);
|
|
81
|
+
expect((0, get_promocode_discount_1.getPromocodeDiscount)({
|
|
82
|
+
...base,
|
|
83
|
+
promocodeType: promocode_type_enum_1.PromocodeTypeEnum.PERCENT_BASE_SUM,
|
|
84
|
+
promocodeDiscount: 1500,
|
|
85
|
+
})).toBe(1500);
|
|
86
|
+
expect((0, get_promocode_discount_1.getPromocodeDiscount)({
|
|
87
|
+
...base,
|
|
88
|
+
promocodeType: promocode_type_enum_1.PromocodeTypeEnum.PROMOTIONAL,
|
|
89
|
+
promocodeDiscount: 1500,
|
|
90
|
+
})).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Классификатор «это точно не квест» по типам Google Places + имени.
|
|
3
|
+
*
|
|
4
|
+
* Дискавери ищет Text Search'ем «escape room …», и в выдачу попадает много
|
|
5
|
+
* мусора: отели, рестораны, боулинги, музеи, магазины, детсады и т.п. Google
|
|
6
|
+
* не отдаёт отдельный тип `escape_room`, поэтому позитивно опознать квест по
|
|
7
|
+
* типам нельзя — но можно отсеять заведомо-не-квесты.
|
|
8
|
+
*
|
|
9
|
+
* Правило намеренно консервативное (лучше пропустить мусор, чем выкинуть
|
|
10
|
+
* настоящий квест): место считается «не квест», только если у него есть
|
|
11
|
+
* жёстко-негативный тип, при этом НЕТ ни одного «квест-правдоподобного» типа
|
|
12
|
+
* И имя не содержит ключевых слов (escape/quest/VR/perform/room …).
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* `true`, если место по типам Google заведомо не эскейп-рум / VR / перформанс.
|
|
16
|
+
* При отсутствии типов или наличии квест-сигналов возвращает `false`
|
|
17
|
+
* (оставляем на ручную модерацию).
|
|
18
|
+
*/
|
|
19
|
+
export declare const isNonQuestByGoogleTypes: (types?: string[] | null, name?: string | null) => boolean;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Классификатор «это точно не квест» по типам Google Places + имени.
|
|
4
|
+
*
|
|
5
|
+
* Дискавери ищет Text Search'ем «escape room …», и в выдачу попадает много
|
|
6
|
+
* мусора: отели, рестораны, боулинги, музеи, магазины, детсады и т.п. Google
|
|
7
|
+
* не отдаёт отдельный тип `escape_room`, поэтому позитивно опознать квест по
|
|
8
|
+
* типам нельзя — но можно отсеять заведомо-не-квесты.
|
|
9
|
+
*
|
|
10
|
+
* Правило намеренно консервативное (лучше пропустить мусор, чем выкинуть
|
|
11
|
+
* настоящий квест): место считается «не квест», только если у него есть
|
|
12
|
+
* жёстко-негативный тип, при этом НЕТ ни одного «квест-правдоподобного» типа
|
|
13
|
+
* И имя не содержит ключевых слов (escape/quest/VR/perform/room …).
|
|
14
|
+
*/
|
|
15
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
16
|
+
exports.isNonQuestByGoogleTypes = void 0;
|
|
17
|
+
/**
|
|
18
|
+
* Типы, которые встречаются у настоящих квестов / VR / иммерсивных
|
|
19
|
+
* перформансов. Если хоть один присутствует — место не отсеиваем.
|
|
20
|
+
*/
|
|
21
|
+
const ESCAPE_PLAUSIBLE_TYPES = new Set([
|
|
22
|
+
'amusement_center',
|
|
23
|
+
'amusement_park',
|
|
24
|
+
'video_arcade',
|
|
25
|
+
'escape_room',
|
|
26
|
+
]);
|
|
27
|
+
/**
|
|
28
|
+
* Типы, которые у эскейп-румов / VR / перформансов практически не встречаются.
|
|
29
|
+
* Наличие любого из них (при отсутствии `ESCAPE_PLAUSIBLE_TYPES` и ключевых
|
|
30
|
+
* слов в имени) — сигнал «не квест».
|
|
31
|
+
*
|
|
32
|
+
* Намеренно НЕ включены неоднозначные, часто соседствующие с квестами типы:
|
|
33
|
+
* `point_of_interest`, `establishment`, `tourist_attraction`, `event_venue`,
|
|
34
|
+
* `sports_activity_location`, `sports_complex`, `tour_agency`, `travel_agency`
|
|
35
|
+
* и пр. — они остаются на ручную модерацию.
|
|
36
|
+
*/
|
|
37
|
+
const HARD_NEGATIVE_TYPES = new Set([
|
|
38
|
+
// проживание
|
|
39
|
+
'lodging',
|
|
40
|
+
'hotel',
|
|
41
|
+
'motel',
|
|
42
|
+
'resort_hotel',
|
|
43
|
+
'inn',
|
|
44
|
+
'guest_house',
|
|
45
|
+
'bed_and_breakfast',
|
|
46
|
+
'cottage',
|
|
47
|
+
'campground',
|
|
48
|
+
'hostel',
|
|
49
|
+
'extended_stay_hotel',
|
|
50
|
+
'ski_resort',
|
|
51
|
+
'farmstay',
|
|
52
|
+
'private_guest_room',
|
|
53
|
+
'guest_room',
|
|
54
|
+
// еда / напитки
|
|
55
|
+
'restaurant',
|
|
56
|
+
'meal_takeaway',
|
|
57
|
+
'meal_delivery',
|
|
58
|
+
'cafe',
|
|
59
|
+
'cafeteria',
|
|
60
|
+
'coffee_shop',
|
|
61
|
+
'bakery',
|
|
62
|
+
'bar',
|
|
63
|
+
'bar_and_grill',
|
|
64
|
+
'cocktail_bar',
|
|
65
|
+
'wine_bar',
|
|
66
|
+
'pub',
|
|
67
|
+
'night_club',
|
|
68
|
+
'dessert_shop',
|
|
69
|
+
'ice_cream_shop',
|
|
70
|
+
'confectionery',
|
|
71
|
+
'donut_shop',
|
|
72
|
+
'sandwich_shop',
|
|
73
|
+
'fast_food_restaurant',
|
|
74
|
+
'diner',
|
|
75
|
+
'deli',
|
|
76
|
+
'food_court',
|
|
77
|
+
'tea_house',
|
|
78
|
+
'juice_shop',
|
|
79
|
+
'hookah_bar',
|
|
80
|
+
'lounge_bar',
|
|
81
|
+
'food_store',
|
|
82
|
+
// красота / велнес
|
|
83
|
+
'spa',
|
|
84
|
+
'sauna',
|
|
85
|
+
'public_bath',
|
|
86
|
+
'massage',
|
|
87
|
+
'massage_spa',
|
|
88
|
+
'beauty_salon',
|
|
89
|
+
'hair_salon',
|
|
90
|
+
'hair_care',
|
|
91
|
+
'nail_salon',
|
|
92
|
+
'barber_shop',
|
|
93
|
+
'gym',
|
|
94
|
+
'fitness_center',
|
|
95
|
+
'swimming_pool',
|
|
96
|
+
'wellness_center',
|
|
97
|
+
'yoga_studio',
|
|
98
|
+
'tanning_studio',
|
|
99
|
+
// ритейл
|
|
100
|
+
'store',
|
|
101
|
+
'shopping_mall',
|
|
102
|
+
'supermarket',
|
|
103
|
+
'grocery_store',
|
|
104
|
+
'convenience_store',
|
|
105
|
+
'department_store',
|
|
106
|
+
'market',
|
|
107
|
+
'wholesaler',
|
|
108
|
+
// культура / достопримечательности (не квест)
|
|
109
|
+
'museum',
|
|
110
|
+
'history_museum',
|
|
111
|
+
'art_gallery',
|
|
112
|
+
'historical_landmark',
|
|
113
|
+
'historical_place',
|
|
114
|
+
'castle',
|
|
115
|
+
'monument',
|
|
116
|
+
'church',
|
|
117
|
+
'place_of_worship',
|
|
118
|
+
'mosque',
|
|
119
|
+
'synagogue',
|
|
120
|
+
'hindu_temple',
|
|
121
|
+
'cemetery',
|
|
122
|
+
'library',
|
|
123
|
+
// образование
|
|
124
|
+
'school',
|
|
125
|
+
'preschool',
|
|
126
|
+
'primary_school',
|
|
127
|
+
'secondary_school',
|
|
128
|
+
'university',
|
|
129
|
+
'child_care_agency',
|
|
130
|
+
'educational_institution',
|
|
131
|
+
// медицина
|
|
132
|
+
'hospital',
|
|
133
|
+
'doctor',
|
|
134
|
+
'dentist',
|
|
135
|
+
'pharmacy',
|
|
136
|
+
'drugstore',
|
|
137
|
+
'clinic',
|
|
138
|
+
'veterinary_care',
|
|
139
|
+
'physiotherapist',
|
|
140
|
+
'medical_lab',
|
|
141
|
+
// авто
|
|
142
|
+
'gas_station',
|
|
143
|
+
'car_repair',
|
|
144
|
+
'car_dealer',
|
|
145
|
+
'car_wash',
|
|
146
|
+
'car_rental',
|
|
147
|
+
'parking',
|
|
148
|
+
'electric_vehicle_charging_station',
|
|
149
|
+
// офис / госуслуги / сервисы
|
|
150
|
+
'government_office',
|
|
151
|
+
'local_government_office',
|
|
152
|
+
'city_hall',
|
|
153
|
+
'courthouse',
|
|
154
|
+
'embassy',
|
|
155
|
+
'police',
|
|
156
|
+
'fire_station',
|
|
157
|
+
'post_office',
|
|
158
|
+
'coworking_space',
|
|
159
|
+
'corporate_office',
|
|
160
|
+
'lawyer',
|
|
161
|
+
'accounting',
|
|
162
|
+
'insurance_agency',
|
|
163
|
+
'bank',
|
|
164
|
+
'atm',
|
|
165
|
+
'real_estate_agency',
|
|
166
|
+
'consultant',
|
|
167
|
+
'manufacturer',
|
|
168
|
+
'moving_company',
|
|
169
|
+
'storage',
|
|
170
|
+
'plumber',
|
|
171
|
+
'electrician',
|
|
172
|
+
// природа (не квест)
|
|
173
|
+
'hiking_area',
|
|
174
|
+
'farm',
|
|
175
|
+
'park',
|
|
176
|
+
'national_park',
|
|
177
|
+
// специфический спорт / развлечения (не квест / VR / перформанс)
|
|
178
|
+
'bowling_alley',
|
|
179
|
+
'miniature_golf_course',
|
|
180
|
+
'go_karting_venue',
|
|
181
|
+
'race_course',
|
|
182
|
+
'golf_course',
|
|
183
|
+
'paintball_center',
|
|
184
|
+
'tennis_court',
|
|
185
|
+
'indoor_playground',
|
|
186
|
+
'playground',
|
|
187
|
+
]);
|
|
188
|
+
/**
|
|
189
|
+
* Ключевые слова в имени, которые страхуют от ложного отсева: если Google
|
|
190
|
+
* ошибочно навесил на квест тип вроде `night_club`/`cafe`/`bar`, но в названии
|
|
191
|
+
* есть явный маркер — оставляем на модерацию.
|
|
192
|
+
*/
|
|
193
|
+
const QUEST_NAME_HINT = /(escape|quest|квест|puzzle|mystery|riddle|virtual reality|immersi|иммерс|перформанс|perform|sandbox|zero latency|fluchtraum|fluchtspiel|exit game|room|вирт|\bvr\b)/i;
|
|
194
|
+
/** Динамические негативные типы вида `italian_restaurant`, `toy_store`, `gift_shop`. */
|
|
195
|
+
const HARD_NEGATIVE_SUFFIX = /_(restaurant|store|shop)$/;
|
|
196
|
+
const isHardNegativeType = (type) => HARD_NEGATIVE_TYPES.has(type) || HARD_NEGATIVE_SUFFIX.test(type);
|
|
197
|
+
/**
|
|
198
|
+
* `true`, если место по типам Google заведомо не эскейп-рум / VR / перформанс.
|
|
199
|
+
* При отсутствии типов или наличии квест-сигналов возвращает `false`
|
|
200
|
+
* (оставляем на ручную модерацию).
|
|
201
|
+
*/
|
|
202
|
+
const isNonQuestByGoogleTypes = (types, name) => {
|
|
203
|
+
if (!types || types.length === 0)
|
|
204
|
+
return false;
|
|
205
|
+
if (name && QUEST_NAME_HINT.test(name))
|
|
206
|
+
return false;
|
|
207
|
+
if (types.some((type) => ESCAPE_PLAUSIBLE_TYPES.has(type)))
|
|
208
|
+
return false;
|
|
209
|
+
return types.some(isHardNegativeType);
|
|
210
|
+
};
|
|
211
|
+
exports.isNonQuestByGoogleTypes = isNonQuestByGoogleTypes;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './agreement-config';
|
|
2
2
|
export * from './booking-lead-time';
|
|
3
3
|
export * from './booking-page-url';
|
|
4
|
+
export * from './city-quest-content';
|
|
4
5
|
export * from './classify-api-error';
|
|
5
6
|
export * from './convert-hhmm-to-minutes';
|
|
6
7
|
export * from './convert-minutes-to-hhmm';
|
|
@@ -11,10 +12,11 @@ export * from './format-flex-duration-label';
|
|
|
11
12
|
export * from './functional-popover-panel-style';
|
|
12
13
|
export * from './get-documents-links';
|
|
13
14
|
export * from './get-full-name';
|
|
14
|
-
export * from './google-places-link-warnings';
|
|
15
15
|
export * from './get-handled-error-message';
|
|
16
16
|
export * from './get-order-cancelation-state';
|
|
17
17
|
export * from './get-service-error';
|
|
18
|
+
export * from './google-places-link-warnings';
|
|
19
|
+
export * from './google-places-nonquest';
|
|
18
20
|
export * from './image-url';
|
|
19
21
|
export * from './is-axios-error';
|
|
20
22
|
export * from './is-network-error';
|
|
@@ -26,6 +28,7 @@ export * from './promocode-nominal-rules';
|
|
|
26
28
|
export * from './redirect';
|
|
27
29
|
export * from './role-chat-permissions';
|
|
28
30
|
export * from './role-export-permissions';
|
|
31
|
+
export * from './route-warnings';
|
|
29
32
|
export * from './serialize-record';
|
|
30
33
|
export * from './serialize-slot';
|
|
31
34
|
export * from './slot-representative-price';
|
package/dist/index.js
CHANGED
|
@@ -17,6 +17,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
17
17
|
__exportStar(require("./agreement-config"), exports);
|
|
18
18
|
__exportStar(require("./booking-lead-time"), exports);
|
|
19
19
|
__exportStar(require("./booking-page-url"), exports);
|
|
20
|
+
__exportStar(require("./city-quest-content"), exports);
|
|
20
21
|
__exportStar(require("./classify-api-error"), exports);
|
|
21
22
|
__exportStar(require("./convert-hhmm-to-minutes"), exports);
|
|
22
23
|
__exportStar(require("./convert-minutes-to-hhmm"), exports);
|
|
@@ -27,10 +28,11 @@ __exportStar(require("./format-flex-duration-label"), 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
|
-
__exportStar(require("./google-places-link-warnings"), exports);
|
|
31
31
|
__exportStar(require("./get-handled-error-message"), exports);
|
|
32
32
|
__exportStar(require("./get-order-cancelation-state"), exports);
|
|
33
33
|
__exportStar(require("./get-service-error"), exports);
|
|
34
|
+
__exportStar(require("./google-places-link-warnings"), exports);
|
|
35
|
+
__exportStar(require("./google-places-nonquest"), exports);
|
|
34
36
|
__exportStar(require("./image-url"), exports);
|
|
35
37
|
__exportStar(require("./is-axios-error"), exports);
|
|
36
38
|
__exportStar(require("./is-network-error"), exports);
|
|
@@ -42,6 +44,7 @@ __exportStar(require("./promocode-nominal-rules"), exports);
|
|
|
42
44
|
__exportStar(require("./redirect"), exports);
|
|
43
45
|
__exportStar(require("./role-chat-permissions"), exports);
|
|
44
46
|
__exportStar(require("./role-export-permissions"), exports);
|
|
47
|
+
__exportStar(require("./route-warnings"), exports);
|
|
45
48
|
__exportStar(require("./serialize-record"), exports);
|
|
46
49
|
__exportStar(require("./serialize-slot"), exports);
|
|
47
50
|
__exportStar(require("./slot-representative-price"), exports);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type RoutePoint = {
|
|
2
|
+
lat: number;
|
|
3
|
+
lng: number;
|
|
4
|
+
};
|
|
5
|
+
export type NeighborDistanceWarning = {
|
|
6
|
+
fromIndex: number;
|
|
7
|
+
toIndex: number;
|
|
8
|
+
distanceKm: number;
|
|
9
|
+
level: 'yellow' | 'red';
|
|
10
|
+
};
|
|
11
|
+
export type RouteWarningsResult = {
|
|
12
|
+
neighborWarnings: NeighborDistanceWarning[];
|
|
13
|
+
sequenceWarning: boolean;
|
|
14
|
+
totalDistanceKm: number;
|
|
15
|
+
/** Rough walking time in minutes (distance @ 4 km/h + 8 min per stop). */
|
|
16
|
+
estimatedMinutes: number;
|
|
17
|
+
nearestNeighborDistanceKm: number;
|
|
18
|
+
};
|
|
19
|
+
/** Haversine distance in kilometres. */
|
|
20
|
+
export declare const haversineKm: (a: RoutePoint, b: RoutePoint) => number;
|
|
21
|
+
/** Greedy nearest-neighbor tour length starting at index 0. */
|
|
22
|
+
export declare const nearestNeighborLengthKm: (points: RoutePoint[]) => number;
|
|
23
|
+
export declare const analyzeRoute: (points: RoutePoint[]) => RouteWarningsResult;
|
|
24
|
+
/** Soft guidance for story length (not blocking). */
|
|
25
|
+
export declare const ROUTE_SOFT_LIMITS: {
|
|
26
|
+
minSteps: number;
|
|
27
|
+
maxSteps: number;
|
|
28
|
+
minTotalKm: number;
|
|
29
|
+
maxTotalKm: number;
|
|
30
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ROUTE_SOFT_LIMITS = exports.analyzeRoute = exports.nearestNeighborLengthKm = exports.haversineKm = void 0;
|
|
4
|
+
const EARTH_RADIUS_KM = 6371;
|
|
5
|
+
const WALK_KMH = 4;
|
|
6
|
+
const MINUTES_PER_STOP = 8;
|
|
7
|
+
const YELLOW_KM = 1;
|
|
8
|
+
const RED_KM = 2;
|
|
9
|
+
const SEQUENCE_RATIO = 1.25;
|
|
10
|
+
const TURNBACK_DEG = 135;
|
|
11
|
+
/** Haversine distance in kilometres. */
|
|
12
|
+
const haversineKm = (a, b) => {
|
|
13
|
+
const toRad = (deg) => (deg * Math.PI) / 180;
|
|
14
|
+
const dLat = toRad(b.lat - a.lat);
|
|
15
|
+
const dLng = toRad(b.lng - a.lng);
|
|
16
|
+
const lat1 = toRad(a.lat);
|
|
17
|
+
const lat2 = toRad(b.lat);
|
|
18
|
+
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLng / 2) ** 2;
|
|
19
|
+
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
|
20
|
+
};
|
|
21
|
+
exports.haversineKm = haversineKm;
|
|
22
|
+
const bearingDeg = (a, b) => {
|
|
23
|
+
const toRad = (deg) => (deg * Math.PI) / 180;
|
|
24
|
+
const toDeg = (rad) => (rad * 180) / Math.PI;
|
|
25
|
+
const lat1 = toRad(a.lat);
|
|
26
|
+
const lat2 = toRad(b.lat);
|
|
27
|
+
const dLng = toRad(b.lng - a.lng);
|
|
28
|
+
const y = Math.sin(dLng) * Math.cos(lat2);
|
|
29
|
+
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
|
|
30
|
+
return (toDeg(Math.atan2(y, x)) + 360) % 360;
|
|
31
|
+
};
|
|
32
|
+
const angleDiffDeg = (a, b) => {
|
|
33
|
+
const d = Math.abs(a - b) % 360;
|
|
34
|
+
return d > 180 ? 360 - d : d;
|
|
35
|
+
};
|
|
36
|
+
/** Greedy nearest-neighbor tour length starting at index 0. */
|
|
37
|
+
const nearestNeighborLengthKm = (points) => {
|
|
38
|
+
if (points.length < 2)
|
|
39
|
+
return 0;
|
|
40
|
+
const remaining = new Set(points.map((_, i) => i));
|
|
41
|
+
let current = 0;
|
|
42
|
+
remaining.delete(0);
|
|
43
|
+
let total = 0;
|
|
44
|
+
while (remaining.size) {
|
|
45
|
+
let best = -1;
|
|
46
|
+
let bestDist = Infinity;
|
|
47
|
+
remaining.forEach((idx) => {
|
|
48
|
+
const d = (0, exports.haversineKm)(points[current], points[idx]);
|
|
49
|
+
if (d < bestDist) {
|
|
50
|
+
bestDist = d;
|
|
51
|
+
best = idx;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
total += bestDist;
|
|
55
|
+
current = best;
|
|
56
|
+
remaining.delete(best);
|
|
57
|
+
}
|
|
58
|
+
return total;
|
|
59
|
+
};
|
|
60
|
+
exports.nearestNeighborLengthKm = nearestNeighborLengthKm;
|
|
61
|
+
const analyzeRoute = (points) => {
|
|
62
|
+
const neighborWarnings = [];
|
|
63
|
+
let totalDistanceKm = 0;
|
|
64
|
+
for (let i = 0; i < points.length - 1; i++) {
|
|
65
|
+
const distanceKm = (0, exports.haversineKm)(points[i], points[i + 1]);
|
|
66
|
+
totalDistanceKm += distanceKm;
|
|
67
|
+
if (distanceKm > RED_KM) {
|
|
68
|
+
neighborWarnings.push({
|
|
69
|
+
fromIndex: i,
|
|
70
|
+
toIndex: i + 1,
|
|
71
|
+
distanceKm,
|
|
72
|
+
level: 'red',
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
else if (distanceKm > YELLOW_KM) {
|
|
76
|
+
neighborWarnings.push({
|
|
77
|
+
fromIndex: i,
|
|
78
|
+
toIndex: i + 1,
|
|
79
|
+
distanceKm,
|
|
80
|
+
level: 'yellow',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const nearestNeighborDistanceKm = (0, exports.nearestNeighborLengthKm)(points);
|
|
85
|
+
let sequenceWarning = false;
|
|
86
|
+
if (points.length >= 3 && nearestNeighborDistanceKm > 0) {
|
|
87
|
+
if (totalDistanceKm / nearestNeighborDistanceKm >= SEQUENCE_RATIO) {
|
|
88
|
+
sequenceWarning = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (!sequenceWarning && points.length >= 3) {
|
|
92
|
+
for (let i = 0; i < points.length - 2; i++) {
|
|
93
|
+
const b1 = bearingDeg(points[i], points[i + 1]);
|
|
94
|
+
const b2 = bearingDeg(points[i + 1], points[i + 2]);
|
|
95
|
+
if (angleDiffDeg(b1, b2) > TURNBACK_DEG) {
|
|
96
|
+
sequenceWarning = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const estimatedMinutes = Math.round((totalDistanceKm / WALK_KMH) * 60 + points.length * MINUTES_PER_STOP);
|
|
102
|
+
return {
|
|
103
|
+
neighborWarnings,
|
|
104
|
+
sequenceWarning,
|
|
105
|
+
totalDistanceKm,
|
|
106
|
+
estimatedMinutes,
|
|
107
|
+
nearestNeighborDistanceKm,
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
exports.analyzeRoute = analyzeRoute;
|
|
111
|
+
/** Soft guidance for story length (not blocking). */
|
|
112
|
+
exports.ROUTE_SOFT_LIMITS = {
|
|
113
|
+
minSteps: 6,
|
|
114
|
+
maxSteps: 12,
|
|
115
|
+
minTotalKm: 2,
|
|
116
|
+
maxTotalKm: 4,
|
|
117
|
+
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.validatePromocode = validatePromocode;
|
|
4
|
+
/* eslint-disable complexity */
|
|
5
|
+
const promocode_type_enum_1 = require("@escapenavigator/types/dist/promocode/emun/promocode-type.enum");
|
|
4
6
|
const date_1 = require("./date");
|
|
5
7
|
const promocode_error_codes_1 = require("./promocode-error-codes");
|
|
6
8
|
function getWeekdayNumber(date) {
|
|
@@ -35,7 +37,12 @@ function validatePromocode({ promocode, players, questroomId, timeZone, orderDat
|
|
|
35
37
|
else if (promocode.applyings > 0) {
|
|
36
38
|
errors.push({ code: promocode_error_codes_1.PromocodeErrorCode.SINGLE_USE });
|
|
37
39
|
}
|
|
38
|
-
|
|
40
|
+
// Флеш-цена поглощает слот-скидку (итог за игроков всегда = флеш-цене),
|
|
41
|
+
// поэтому скидка слота не считается конфликтом — блокируем только
|
|
42
|
+
// комбинирование с другими промокодами.
|
|
43
|
+
const slotDiscountConflict = promocode.type === promocode_type_enum_1.PromocodeTypeEnum.FLASH_PRICE ? 0 : slotDiscount;
|
|
44
|
+
if (!promocode.canUseWithOtherPromocodes &&
|
|
45
|
+
(otherPromocodes.length || slotDiscountConflict)) {
|
|
39
46
|
errors.push({ code: promocode_error_codes_1.PromocodeErrorCode.NOT_COMBINABLE_WITH_OTHER_DISCOUNTS });
|
|
40
47
|
}
|
|
41
48
|
if (!promocode.canUseWithOtherCertificates && hasCertificates) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@escapenavigator/utils",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.17",
|
|
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.
|
|
17
|
+
"@escapenavigator/types": "^2.0.16",
|
|
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": "
|
|
32
|
+
"gitHead": "758cd8c4842a26ad44e69154e1263dfd0ad4ad9f"
|
|
33
33
|
}
|