@escapenavigator/utils 1.10.107 → 1.10.109

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 (53) hide show
  1. package/dist/date-timezone.d.ts +5 -0
  2. package/dist/date-timezone.js +190 -0
  3. package/dist/date.d.ts +53 -0
  4. package/dist/date.js +362 -0
  5. package/dist/date.test.d.ts +1 -0
  6. package/dist/date.test.js +61 -0
  7. package/dist/format-amount/index.js +12 -12
  8. package/dist/get-documents-links.js +1 -2
  9. package/dist/get-email-result-section.js +1 -2
  10. package/dist/get-email-upsales-section.js +1 -2
  11. package/dist/get-full-name.js +1 -1
  12. package/dist/get-handled-error-message.js +1 -1
  13. package/dist/get-order-cancelation-date/index.js +4 -10
  14. package/dist/get-order-cancelation-state/index.js +12 -11
  15. package/dist/get-order-cancelation-state/index.test.js +5 -5
  16. package/dist/get-players-price.js +1 -1
  17. package/dist/get-promocode-discount.js +2 -0
  18. package/dist/get-service-error.js +3 -3
  19. package/dist/get-slot-cancelation-date/index.d.ts +3 -1
  20. package/dist/get-slot-cancelation-date/index.js +11 -14
  21. package/dist/get-slot-cancelation-date/index.test.js +4 -8
  22. package/dist/get-static-color.js +1 -2
  23. package/dist/has-text-outside-tags.js +1 -2
  24. package/dist/index.d.ts +9 -3
  25. package/dist/index.js +9 -3
  26. package/dist/is-axios-error.js +1 -2
  27. package/dist/is-network-error.js +1 -2
  28. package/dist/pick/index.d.ts +2 -0
  29. package/dist/pick/index.js +16 -0
  30. package/dist/pick/index.test.d.ts +1 -0
  31. package/dist/pick/index.test.js +23 -0
  32. package/dist/promocode-error-codes.d.ts +72 -0
  33. package/dist/promocode-error-codes.js +61 -0
  34. package/dist/promocode-nominal-rules.d.ts +29 -0
  35. package/dist/promocode-nominal-rules.js +39 -0
  36. package/dist/promocode-nominal-rules.spec.d.ts +1 -0
  37. package/dist/promocode-nominal-rules.spec.js +74 -0
  38. package/dist/serialize-slot.d.ts +1 -20
  39. package/dist/serialize-slot.js +8 -78
  40. package/dist/transform-text.js +6 -4
  41. package/dist/tz-date.d.ts +1 -1
  42. package/dist/tz-date.js +8 -9
  43. package/dist/utm-touchpoints.d.ts +63 -0
  44. package/dist/utm-touchpoints.js +66 -0
  45. package/dist/utm-touchpoints.spec.d.ts +1 -0
  46. package/dist/utm-touchpoints.spec.js +95 -0
  47. package/dist/validate-by-dto.d.ts +1 -1
  48. package/dist/validate-by-dto.js +2 -3
  49. package/dist/validate-promocode.d.ts +9 -4
  50. package/dist/validate-promocode.js +57 -38
  51. package/dist/validate-promocode.spec.d.ts +1 -0
  52. package/dist/validate-promocode.spec.js +129 -0
  53. package/package.json +4 -25
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PromocodeErrorCode = void 0;
4
+ exports.formatPromocodeErrorFallback = formatPromocodeErrorFallback;
5
+ var PromocodeErrorCode;
6
+ (function (PromocodeErrorCode) {
7
+ PromocodeErrorCode["NOT_AVAILABLE_FOR_CERTIFICATES"] = "NOT_AVAILABLE_FOR_CERTIFICATES";
8
+ PromocodeErrorCode["NOT_AVAILABLE_FOR_BOOKINGS"] = "NOT_AVAILABLE_FOR_BOOKINGS";
9
+ PromocodeErrorCode["ALREADY_APPLIED"] = "ALREADY_APPLIED";
10
+ PromocodeErrorCode["INSIDE_ONLY"] = "INSIDE_ONLY";
11
+ PromocodeErrorCode["MAX_APPLYINGS_REACHED"] = "MAX_APPLYINGS_REACHED";
12
+ PromocodeErrorCode["SINGLE_USE"] = "SINGLE_USE";
13
+ PromocodeErrorCode["NOT_COMBINABLE_WITH_OTHER_DISCOUNTS"] = "NOT_COMBINABLE_WITH_OTHER_DISCOUNTS";
14
+ PromocodeErrorCode["NOT_COMBINABLE_WITH_CERTIFICATES"] = "NOT_COMBINABLE_WITH_CERTIFICATES";
15
+ PromocodeErrorCode["NOT_VALID_YET"] = "NOT_VALID_YET";
16
+ PromocodeErrorCode["EXPIRED"] = "EXPIRED";
17
+ PromocodeErrorCode["NOT_FOR_ORDER_DATE_FROM"] = "NOT_FOR_ORDER_DATE_FROM";
18
+ PromocodeErrorCode["NOT_FOR_ORDER_DATE_TO"] = "NOT_FOR_ORDER_DATE_TO";
19
+ PromocodeErrorCode["QUESTROOM_NOT_ALLOWED"] = "QUESTROOM_NOT_ALLOWED";
20
+ PromocodeErrorCode["NOT_AVAILABLE_AT_THIS_TIME_FROM"] = "NOT_AVAILABLE_AT_THIS_TIME_FROM";
21
+ PromocodeErrorCode["NOT_AVAILABLE_AT_THIS_TIME_TO"] = "NOT_AVAILABLE_AT_THIS_TIME_TO";
22
+ PromocodeErrorCode["NOT_AVAILABLE_THIS_DAY"] = "NOT_AVAILABLE_THIS_DAY";
23
+ PromocodeErrorCode["MIN_PLAYERS"] = "MIN_PLAYERS";
24
+ PromocodeErrorCode["MAX_PLAYERS"] = "MAX_PLAYERS";
25
+ })(PromocodeErrorCode || (exports.PromocodeErrorCode = PromocodeErrorCode = {}));
26
+ const FALLBACK_TEXT = {
27
+ [PromocodeErrorCode.NOT_AVAILABLE_FOR_CERTIFICATES]: "The promocode can't be applied for certificates.",
28
+ [PromocodeErrorCode.NOT_AVAILABLE_FOR_BOOKINGS]: "The promocode can't be applied for bookings.",
29
+ [PromocodeErrorCode.ALREADY_APPLIED]: 'Promocode already applied.',
30
+ [PromocodeErrorCode.INSIDE_ONLY]: 'The promocode can be applied by phone.',
31
+ [PromocodeErrorCode.MAX_APPLYINGS_REACHED]: 'The maximum number of promocode applications has been reached.',
32
+ [PromocodeErrorCode.SINGLE_USE]: 'The promocode can only be used once.',
33
+ [PromocodeErrorCode.NOT_COMBINABLE_WITH_OTHER_DISCOUNTS]: 'The promocode cannot be used with other promocodes or discounts.',
34
+ [PromocodeErrorCode.NOT_COMBINABLE_WITH_CERTIFICATES]: 'The promocode cannot be used with other certificates.',
35
+ [PromocodeErrorCode.NOT_VALID_YET]: 'The promocode is not valid yet.',
36
+ [PromocodeErrorCode.EXPIRED]: 'The promocode has expired.',
37
+ [PromocodeErrorCode.NOT_FOR_ORDER_DATE_FROM]: 'The promocode is not valid for the selected game date.',
38
+ [PromocodeErrorCode.NOT_FOR_ORDER_DATE_TO]: 'The promocode is not valid for the selected game date.',
39
+ [PromocodeErrorCode.QUESTROOM_NOT_ALLOWED]: 'The promocode is not valid for the selected escape room.',
40
+ [PromocodeErrorCode.NOT_AVAILABLE_AT_THIS_TIME_FROM]: 'The promocode is not available at this time.',
41
+ [PromocodeErrorCode.NOT_AVAILABLE_AT_THIS_TIME_TO]: 'The promocode is not available at this time.',
42
+ [PromocodeErrorCode.NOT_AVAILABLE_THIS_DAY]: 'The promocode is not valid for the selected day.',
43
+ [PromocodeErrorCode.MIN_PLAYERS]: 'Not enough players for the promocode.',
44
+ [PromocodeErrorCode.MAX_PLAYERS]: 'Too many players for the promocode.',
45
+ };
46
+ /**
47
+ * Английский fallback для отображения кода ошибки промокода вне i18n-контекста.
48
+ * Для пользовательского UI нужно использовать i18n-ключи вида
49
+ * `errors.promocode.<CODE>` с подстановкой `payload`. Эта функция — последний
50
+ * рубеж, чтобы при ошибке всегда было что показать.
51
+ */
52
+ function formatPromocodeErrorFallback(error) {
53
+ const text = FALLBACK_TEXT[error.code];
54
+ if ('payload' in error && error.payload) {
55
+ const parts = Object.entries(error.payload)
56
+ .map(([key, value]) => `${key}: ${value}`)
57
+ .join(', ');
58
+ return `${text} (${parts})`;
59
+ }
60
+ return text;
61
+ }
@@ -0,0 +1,29 @@
1
+ import { PromocodeNominalRule } from '@escapenavigator/types/dist/promocode/promocode-nominal-rule';
2
+ type NominalLike = {
3
+ id: number;
4
+ discount?: number;
5
+ };
6
+ type PromocodeLike = {
7
+ availableForCertificates?: boolean;
8
+ certificateNominalRules?: PromocodeNominalRule[];
9
+ };
10
+ /**
11
+ * Поиск правила промокода для конкретного номинала. Возвращает запись из
12
+ * `certificateNominalRules` или `null`, если правила нет.
13
+ */
14
+ export declare function findNominalRule(promocode: PromocodeLike | null | undefined, nominalId: number): PromocodeNominalRule | null;
15
+ /**
16
+ * Применим ли промокод к данному номиналу: scope охватывает сертификаты и есть
17
+ * явное правило для `nominal.id`. Если правил нет — промокод к номиналу не
18
+ * применяется (магазин показывает базовую скидку номинала).
19
+ */
20
+ export declare function isPromocodeApplicableToNominal(promocode: PromocodeLike | null | undefined, nominal: NominalLike): boolean;
21
+ /**
22
+ * Эффективная скидка номинала с учётом промокода (если он применим).
23
+ * Возвращает значение в минимальных единицах валюты.
24
+ *
25
+ * - Промокод применим к номиналу → скидка из правила.
26
+ * - Иначе — базовый `nominal.discount` (или 0).
27
+ */
28
+ export declare function resolveEffectiveNominalDiscount(promocode: PromocodeLike | null | undefined, nominal: NominalLike): number;
29
+ export {};
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.findNominalRule = findNominalRule;
4
+ exports.isPromocodeApplicableToNominal = isPromocodeApplicableToNominal;
5
+ exports.resolveEffectiveNominalDiscount = resolveEffectiveNominalDiscount;
6
+ /**
7
+ * Поиск правила промокода для конкретного номинала. Возвращает запись из
8
+ * `certificateNominalRules` или `null`, если правила нет.
9
+ */
10
+ function findNominalRule(promocode, nominalId) {
11
+ if (!promocode?.certificateNominalRules?.length)
12
+ return null;
13
+ return promocode.certificateNominalRules.find((r) => r.nominalId === nominalId) ?? null;
14
+ }
15
+ /**
16
+ * Применим ли промокод к данному номиналу: scope охватывает сертификаты и есть
17
+ * явное правило для `nominal.id`. Если правил нет — промокод к номиналу не
18
+ * применяется (магазин показывает базовую скидку номинала).
19
+ */
20
+ function isPromocodeApplicableToNominal(promocode, nominal) {
21
+ if (!promocode?.availableForCertificates)
22
+ return false;
23
+ return findNominalRule(promocode, nominal.id) !== null;
24
+ }
25
+ /**
26
+ * Эффективная скидка номинала с учётом промокода (если он применим).
27
+ * Возвращает значение в минимальных единицах валюты.
28
+ *
29
+ * - Промокод применим к номиналу → скидка из правила.
30
+ * - Иначе — базовый `nominal.discount` (или 0).
31
+ */
32
+ function resolveEffectiveNominalDiscount(promocode, nominal) {
33
+ const rule = isPromocodeApplicableToNominal(promocode, nominal)
34
+ ? findNominalRule(promocode, nominal.id)
35
+ : null;
36
+ if (rule)
37
+ return Math.max(0, rule.discount || 0);
38
+ return Math.max(0, nominal.discount || 0);
39
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const promocode_nominal_rules_1 = require("./promocode-nominal-rules");
4
+ const baseNominal = { id: 10, discount: 500 };
5
+ const promocodeWithRule = {
6
+ availableForCertificates: true,
7
+ certificateNominalRules: [
8
+ { nominalId: 10, discount: 1500 },
9
+ { nominalId: 20, discount: 700 },
10
+ ],
11
+ };
12
+ const promocodeWithoutRule = {
13
+ availableForCertificates: true,
14
+ certificateNominalRules: [{ nominalId: 99, discount: 1500 }],
15
+ };
16
+ const promocodeBookingsOnly = {
17
+ availableForCertificates: false,
18
+ certificateNominalRules: [{ nominalId: 10, discount: 1500 }],
19
+ };
20
+ describe('findNominalRule', () => {
21
+ it('returns rule when nominal matches', () => {
22
+ expect((0, promocode_nominal_rules_1.findNominalRule)(promocodeWithRule, 10)).toEqual({
23
+ nominalId: 10,
24
+ discount: 1500,
25
+ });
26
+ });
27
+ it('returns null when nominal not in rules', () => {
28
+ expect((0, promocode_nominal_rules_1.findNominalRule)(promocodeWithRule, 999)).toBeNull();
29
+ });
30
+ it('returns null on empty/missing data', () => {
31
+ expect((0, promocode_nominal_rules_1.findNominalRule)(null, 10)).toBeNull();
32
+ expect((0, promocode_nominal_rules_1.findNominalRule)(undefined, 10)).toBeNull();
33
+ expect((0, promocode_nominal_rules_1.findNominalRule)({}, 10)).toBeNull();
34
+ expect((0, promocode_nominal_rules_1.findNominalRule)({ certificateNominalRules: [] }, 10)).toBeNull();
35
+ });
36
+ });
37
+ describe('isPromocodeApplicableToNominal', () => {
38
+ it('true only when scope=certificates and rule exists', () => {
39
+ expect((0, promocode_nominal_rules_1.isPromocodeApplicableToNominal)(promocodeWithRule, baseNominal)).toBe(true);
40
+ });
41
+ it('false when no rule for nominal', () => {
42
+ expect((0, promocode_nominal_rules_1.isPromocodeApplicableToNominal)(promocodeWithoutRule, baseNominal)).toBe(false);
43
+ });
44
+ it('false when scope excludes certificates', () => {
45
+ expect((0, promocode_nominal_rules_1.isPromocodeApplicableToNominal)(promocodeBookingsOnly, baseNominal)).toBe(false);
46
+ });
47
+ it('false on missing promocode', () => {
48
+ expect((0, promocode_nominal_rules_1.isPromocodeApplicableToNominal)(null, baseNominal)).toBe(false);
49
+ });
50
+ });
51
+ describe('resolveEffectiveNominalDiscount', () => {
52
+ it('returns rule discount when applicable', () => {
53
+ expect((0, promocode_nominal_rules_1.resolveEffectiveNominalDiscount)(promocodeWithRule, baseNominal)).toBe(1500);
54
+ });
55
+ it('returns base nominal discount when no rule', () => {
56
+ expect((0, promocode_nominal_rules_1.resolveEffectiveNominalDiscount)(promocodeWithoutRule, baseNominal)).toBe(500);
57
+ });
58
+ it('returns base discount when no promocode', () => {
59
+ expect((0, promocode_nominal_rules_1.resolveEffectiveNominalDiscount)(null, baseNominal)).toBe(500);
60
+ expect((0, promocode_nominal_rules_1.resolveEffectiveNominalDiscount)(undefined, baseNominal)).toBe(500);
61
+ });
62
+ it('clamps negative rule discount to 0', () => {
63
+ expect((0, promocode_nominal_rules_1.resolveEffectiveNominalDiscount)({
64
+ availableForCertificates: true,
65
+ certificateNominalRules: [{ nominalId: 10, discount: -50 }],
66
+ }, baseNominal)).toBe(0);
67
+ });
68
+ it('falls back to base when scope excludes certificates', () => {
69
+ expect((0, promocode_nominal_rules_1.resolveEffectiveNominalDiscount)(promocodeBookingsOnly, baseNominal)).toBe(500);
70
+ });
71
+ it('returns 0 for missing nominal.discount', () => {
72
+ expect((0, promocode_nominal_rules_1.resolveEffectiveNominalDiscount)(null, { id: 10 })).toBe(0);
73
+ });
74
+ });
@@ -8,25 +8,6 @@ type Props = {
8
8
  };
9
9
  export declare const serializeSlotOrderData: (order: OrderRO & {
10
10
  workers: any[];
11
- }) => SlotRO['orders'][number];
12
- /**
13
- * Serializes the internal slot model into the public `SlotRO`.
14
- *
15
- * The list response no longer carries `durations` or `variations`. For flex
16
- * slots top-level fields (status/end/tariff/availability) reflect the minimum
17
- * duration, which is what the slot service has already pre-computed via
18
- * `groupSlotsWithDurationsAndVariations`.
19
- *
20
- * Consumers that need the full duration/variation set should call the
21
- * `/openapi/slots/details` endpoint on demand.
22
- */
11
+ }) => SlotRO["orders"][number];
23
12
  export declare const serializeSlot: ({ slot, orders }: Props) => SlotRO;
24
- /**
25
- * Legacy serializer that emits the old `variations[]` + `variationKind` shape.
26
- *
27
- * Kept only for legacy aggregator endpoints where the public response contract
28
- * cannot be changed (now-escape, ru aggregators). Prefer `serializeSlot` for
29
- * everything else.
30
- */
31
- export declare const serializeSlotLegacy: ({ slot, orders }: Props) => any;
32
13
  export {};
@@ -1,13 +1,11 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.serializeSlotLegacy = exports.serializeSlot = exports.serializeSlotOrderData = void 0;
3
+ exports.serializeSlot = exports.serializeSlotOrderData = void 0;
4
4
  const defailt_rule_1 = require("@escapenavigator/types/dist/slot-rule/defailt-rule");
5
- const get_full_name_1 = require("./get-full-name");
6
5
  const buildRuleSummary = (rule) => {
7
- const { prepayment, prepaymentType, title, minHoursForBooking, minHoursForFreeCanceling, cancelationRule, tax, minHoursForFullFine, cancelationAmount, } = rule || defailt_rule_1.defaultRule;
6
+ const { prepayment, prepaymentType, title, minHoursForBooking, minHoursForFreeCanceling, cancelationRule, minHoursForFullFine, cancelationAmount, } = rule || defailt_rule_1.defaultRule;
8
7
  return {
9
8
  prepayment,
10
- tax,
11
9
  minHoursForFullFine,
12
10
  cancelationAmount,
13
11
  prepaymentType,
@@ -17,13 +15,6 @@ const buildRuleSummary = (rule) => {
17
15
  cancelationRule,
18
16
  };
19
17
  };
20
- const computeDurationMinutes = (start, end, fallback) => {
21
- if (typeof fallback === 'number')
22
- return fallback;
23
- if (!start || !end)
24
- return 0;
25
- return (Math.max(0, new Date(`1970-01-01T${end}`).getTime() - new Date(`1970-01-01T${start}`).getTime()) / 60000);
26
- };
27
18
  const serializeSlotOrderData = (order) => order && {
28
19
  id: order.id,
29
20
  players: order.players,
@@ -36,22 +27,16 @@ const serializeSlotOrderData = (order) => order && {
36
27
  slotDiscount: order.slotDiscount,
37
28
  otherParticipantsIds: order.workers?.map(({ userId }) => userId) || [],
38
29
  code: order.code,
39
- clientName: (0, get_full_name_1.getFullName)(order.client),
40
30
  hold: order.hold,
41
31
  expiration: order.expiration,
32
+ groupId: order.groupId,
33
+ client: {
34
+ id: order.client.id,
35
+ name: order.client.name,
36
+ surname: order.client.surname,
37
+ },
42
38
  };
43
39
  exports.serializeSlotOrderData = serializeSlotOrderData;
44
- /**
45
- * Serializes the internal slot model into the public `SlotRO`.
46
- *
47
- * The list response no longer carries `durations` or `variations`. For flex
48
- * slots top-level fields (status/end/tariff/availability) reflect the minimum
49
- * duration, which is what the slot service has already pre-computed via
50
- * `groupSlotsWithDurationsAndVariations`.
51
- *
52
- * Consumers that need the full duration/variation set should call the
53
- * `/openapi/slots/details` endpoint on demand.
54
- */
55
40
  const serializeSlot = ({ slot, orders = [] }) => ({
56
41
  id: slot.id,
57
42
  questroomId: slot.questroomId,
@@ -73,58 +58,3 @@ const serializeSlot = ({ slot, orders = [] }) => ({
73
58
  orders: orders.map((order) => (0, exports.serializeSlotOrderData)(order)),
74
59
  });
75
60
  exports.serializeSlot = serializeSlot;
76
- /**
77
- * Legacy serializer that emits the old `variations[]` + `variationKind` shape.
78
- *
79
- * Kept only for legacy aggregator endpoints where the public response contract
80
- * cannot be changed (now-escape, ru aggregators). Prefer `serializeSlot` for
81
- * everything else.
82
- */
83
- const serializeSlotLegacy = ({ slot, orders = [] }) => {
84
- const baseVariation = slot.variations?.[0] || slot;
85
- const normalizedVariations = (slot.variations || [baseVariation]).map((variation) => {
86
- const duration = computeDurationMinutes(slot.start, variation.end, variation.duration);
87
- return {
88
- id: variation.id,
89
- tariffId: variation.tariffId,
90
- ruleId: variation.ruleId,
91
- variationKind: variation.variationKind,
92
- end: variation.end,
93
- duration,
94
- tariff: variation.tariff?.price || variation.tariff || {},
95
- status: variation.status,
96
- discount: variation.discount || 0,
97
- onlyPhone: !!variation.onlyPhone,
98
- breakReason: variation.breakReason || null,
99
- forceOnlineBooking: !!variation.forceOnlineBooking,
100
- availableTeams: variation.availableTeams ?? null,
101
- numSeatsAvailable: variation.availablePlayers ?? variation.numSeatsAvailable ?? 0,
102
- rule: buildRuleSummary(variation.rule || slot.rule),
103
- };
104
- });
105
- const primaryVariation = normalizedVariations.find((variation) => variation.status === 'free')
106
- || normalizedVariations.find((variation) => variation.status === 'call')
107
- || normalizedVariations[0];
108
- return {
109
- id: slot.id,
110
- questroomId: slot.questroomId,
111
- old: slot.old,
112
- date: slot.date,
113
- start: slot.start,
114
- defaultVariationId: slot.defaultVariationId ?? slot.tariffId,
115
- variationIds: slot.variationIds || (slot.tariffId != null ? [slot.tariffId] : []),
116
- variations: normalizedVariations,
117
- end: slot.end || primaryVariation?.end,
118
- tariff: primaryVariation?.tariff,
119
- status: primaryVariation?.status,
120
- discount: primaryVariation?.discount,
121
- onlyPhone: primaryVariation?.onlyPhone,
122
- forceOnlineBooking: primaryVariation?.forceOnlineBooking,
123
- breakReason: primaryVariation?.breakReason,
124
- availableTeams: primaryVariation?.availableTeams,
125
- numSeatsAvailable: primaryVariation?.numSeatsAvailable,
126
- rule: primaryVariation?.rule,
127
- orders: orders.map((order) => (0, exports.serializeSlotOrderData)(order)),
128
- };
129
- };
130
- exports.serializeSlotLegacy = serializeSlotLegacy;
@@ -20,11 +20,13 @@ const BOOKING_BUTTON_STYLE = `
20
20
  mso-padding-alt:0px;
21
21
  border-radius:6px;
22
22
  text-align: center;
23
- `.replace(/\n\s*/g, ' ').trim();
23
+ `
24
+ .replace(/\n\s*/g, ' ')
25
+ .trim();
24
26
  const transformText = (text, obj) => {
25
27
  let res = text || '';
26
28
  const bookingButtonRegex = /\{\{#manageBooking\}\}([\s\S]*?)\{\{\/manageBooking\}\}/g;
27
- res = res.replace(bookingButtonRegex, (match, buttonText) => {
29
+ res = res.replace(bookingButtonRegex, (_match, buttonText) => {
28
30
  const link = obj.bookingManagementLink;
29
31
  if (!link)
30
32
  return buttonText;
@@ -32,8 +34,8 @@ const transformText = (text, obj) => {
32
34
  return `<a style="${BOOKING_BUTTON_STYLE}" target="_blank" href="${link}">${trimmedText}</a>`;
33
35
  });
34
36
  const ifRegex = /\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g;
35
- res = res.replace(ifRegex, (match, conditionKey, innerContent) => {
36
- if (obj.hasOwnProperty(conditionKey) && obj[conditionKey]) {
37
+ res = res.replace(ifRegex, (_match, conditionKey, innerContent) => {
38
+ if (Object.hasOwn(obj, conditionKey) && obj[conditionKey]) {
37
39
  return innerContent;
38
40
  }
39
41
  return '';
package/dist/tz-date.d.ts CHANGED
@@ -12,6 +12,6 @@ type ToZoned = {
12
12
  format?: string;
13
13
  showTZ?: boolean;
14
14
  };
15
- export declare const convertDateToZonedDate: ({ date, timeZone, time, offset, }: ToUTC) => Date;
15
+ export declare const convertDateToZonedDate: ({ date, timeZone, time, offset }: ToUTC) => Date;
16
16
  export declare const convertUTCDateToZonedDate: ({ date, timeZone, offset, format, showTZ, }: ToZoned) => string;
17
17
  export {};
package/dist/tz-date.js CHANGED
@@ -1,23 +1,22 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.convertUTCDateToZonedDate = exports.convertDateToZonedDate = void 0;
4
- const date_fns_1 = require("date-fns");
5
- const date_fns_tz_1 = require("date-fns-tz");
6
- const convertDateToZonedDate = ({ date, timeZone, time, offset, }) => {
4
+ const date_1 = require("./date");
5
+ const convertDateToZonedDate = ({ date, timeZone, time, offset }) => {
7
6
  let d = typeof date === 'string'
8
- ? (0, date_fns_1.parse)(time ? `${date} ${time}` : date, time ? 'yyyy-MM-dd HH:mm' : 'yyyy-MM-dd', new Date())
7
+ ? (0, date_1.parseDate)(time ? `${date} ${time}` : date, time ? 'yyyy-MM-dd HH:mm' : 'yyyy-MM-dd')
9
8
  : date;
10
9
  if (offset)
11
- d = (0, date_fns_1.addHours)(d, offset);
12
- return (0, date_fns_tz_1.zonedTimeToUtc)(d, timeZone);
10
+ d = (0, date_1.addHours)(d, offset);
11
+ return (0, date_1.zonedTimeToUtc)(d, timeZone);
13
12
  };
14
13
  exports.convertDateToZonedDate = convertDateToZonedDate;
15
14
  const convertUTCDateToZonedDate = ({ date, timeZone, offset = 0, format = 'dd.MM.yyyy, HH:mm', showTZ = false, }) => {
16
15
  if (!date)
17
16
  return '';
18
- const d = (0, date_fns_tz_1.utcToZonedTime)(date, timeZone);
19
- const dt = (0, date_fns_1.addHours)(d, offset);
20
- const res = (0, date_fns_1.format)(dt, format);
17
+ const d = (0, date_1.utcToZonedTime)(date, timeZone);
18
+ const dt = (0, date_1.addHours)(d, offset);
19
+ const res = (0, date_1.formatDate)(dt, format);
21
20
  return showTZ ? `${res} (${timeZone})` : res;
22
21
  };
23
22
  exports.convertUTCDateToZonedDate = convertUTCDateToZonedDate;
@@ -0,0 +1,63 @@
1
+ export type UtmFields = {
2
+ utmSource?: string | null;
3
+ utmMedium?: string | null;
4
+ utmCampaign?: string | null;
5
+ utmContent?: string | null;
6
+ utmTerm?: string | null;
7
+ /**
8
+ * Внутренний (escapenavigator) ID кампании. В отличие от `utmCampaign`,
9
+ * не редактируется юзером — стабильный FK на
10
+ * `profile-marketing-email-campaign.id`. Используется для точной
11
+ * атрибуции (без LIKE по slug-у) в `computeAttribution`.
12
+ */
13
+ enCampaignId?: number | null;
14
+ /**
15
+ * ID конкретной ссылки внутри письма (например, `cta_primary` или
16
+ * `section_${i}`). Нужен, чтобы считать CTR разных ссылок в одном
17
+ * письме. Last-click: при повторном клике перезаписывается в widget
18
+ * session storage.
19
+ */
20
+ enLinkId?: string | null;
21
+ };
22
+ /**
23
+ * Нормализация одного UTM-поля для индекса/поиска: trim + lowercase.
24
+ * Пустые/undefined значения превращаются в пустую строку, чтобы UNIQUE индекс
25
+ * `(clientId, *Norm)` корректно покрывал «нет такого параметра».
26
+ *
27
+ * NOTE: Postgres-`UNIQUE` рассматривает `NULL` как "не равно `NULL`", поэтому
28
+ * без явной пустой строки две записи с `utmCampaignNorm = NULL` считаются
29
+ * разными и UNIQUE их не отсечёт. Здесь — '' гарантирует дедуп.
30
+ */
31
+ export declare function normalizeUtmField(value: string | null | undefined): string;
32
+ /**
33
+ * Нормализует `enCampaignId`: из любого `number | string | null | undefined`
34
+ * получаем либо положительный integer, либо `null`. Нужно потому что значение
35
+ * может прилететь из URL (строка), из БД (number) или из widget-а (Number).
36
+ */
37
+ export declare function normalizeEnCampaignId(value: number | string | null | undefined): number | null;
38
+ /**
39
+ * Нормализованный объект UTM с гарантированными ключами `*Norm`.
40
+ *
41
+ * `enLinkIdNorm` — пустая строка, если `enLinkId` отсутствует, по той же
42
+ * причине что и остальные `*Norm` (см. комментарий в `normalizeUtmField`).
43
+ * `enCampaignId` остаётся `number | null`; для UNIQUE-индекса мы кладём
44
+ * `0` вместо `null` на стороне сервиса (см. ClientUtmTouchpointService).
45
+ */
46
+ export type NormalizedUtm = {
47
+ utmSourceNorm: string;
48
+ utmMediumNorm: string;
49
+ utmCampaignNorm: string;
50
+ utmContentNorm: string;
51
+ utmTermNorm: string;
52
+ enLinkIdNorm: string;
53
+ enCampaignId: number | null;
54
+ };
55
+ export declare function normalizeUtm(utm: UtmFields | null | undefined): NormalizedUtm;
56
+ /**
57
+ * `true`, если все поля атрибуции пустые после нормализации — такой
58
+ * touchpoint сохранять не нужно (данных нет). С появлением `en_*` «пусто»
59
+ * = пусты И 5 UTM-полей, И `enCampaignId/enLinkId`, иначе пропустим
60
+ * легитимные касания вида «только en_campaign_id, без UTM» (например,
61
+ * если юзер очистит UTM в кампании, но `en_*` мы добавим всегда).
62
+ */
63
+ export declare function isEmptyUtm(utm: UtmFields | null | undefined): boolean;
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.normalizeUtmField = normalizeUtmField;
4
+ exports.normalizeEnCampaignId = normalizeEnCampaignId;
5
+ exports.normalizeUtm = normalizeUtm;
6
+ exports.isEmptyUtm = isEmptyUtm;
7
+ const UTM_STRING_KEYS = [
8
+ 'utmSource',
9
+ 'utmMedium',
10
+ 'utmCampaign',
11
+ 'utmContent',
12
+ 'utmTerm',
13
+ ];
14
+ /**
15
+ * Нормализация одного UTM-поля для индекса/поиска: trim + lowercase.
16
+ * Пустые/undefined значения превращаются в пустую строку, чтобы UNIQUE индекс
17
+ * `(clientId, *Norm)` корректно покрывал «нет такого параметра».
18
+ *
19
+ * NOTE: Postgres-`UNIQUE` рассматривает `NULL` как "не равно `NULL`", поэтому
20
+ * без явной пустой строки две записи с `utmCampaignNorm = NULL` считаются
21
+ * разными и UNIQUE их не отсечёт. Здесь — '' гарантирует дедуп.
22
+ */
23
+ function normalizeUtmField(value) {
24
+ if (value === undefined || value === null)
25
+ return '';
26
+ return String(value).trim().toLowerCase();
27
+ }
28
+ /**
29
+ * Нормализует `enCampaignId`: из любого `number | string | null | undefined`
30
+ * получаем либо положительный integer, либо `null`. Нужно потому что значение
31
+ * может прилететь из URL (строка), из БД (number) или из widget-а (Number).
32
+ */
33
+ function normalizeEnCampaignId(value) {
34
+ if (value === null || value === undefined || value === '')
35
+ return null;
36
+ const n = typeof value === 'number' ? value : Number(value);
37
+ if (!Number.isFinite(n) || n <= 0)
38
+ return null;
39
+ return Math.trunc(n);
40
+ }
41
+ function normalizeUtm(utm) {
42
+ return {
43
+ utmSourceNorm: normalizeUtmField(utm?.utmSource),
44
+ utmMediumNorm: normalizeUtmField(utm?.utmMedium),
45
+ utmCampaignNorm: normalizeUtmField(utm?.utmCampaign),
46
+ utmContentNorm: normalizeUtmField(utm?.utmContent),
47
+ utmTermNorm: normalizeUtmField(utm?.utmTerm),
48
+ enLinkIdNorm: normalizeUtmField(utm?.enLinkId),
49
+ enCampaignId: normalizeEnCampaignId(utm?.enCampaignId),
50
+ };
51
+ }
52
+ /**
53
+ * `true`, если все поля атрибуции пустые после нормализации — такой
54
+ * touchpoint сохранять не нужно (данных нет). С появлением `en_*` «пусто»
55
+ * = пусты И 5 UTM-полей, И `enCampaignId/enLinkId`, иначе пропустим
56
+ * легитимные касания вида «только en_campaign_id, без UTM» (например,
57
+ * если юзер очистит UTM в кампании, но `en_*` мы добавим всегда).
58
+ */
59
+ function isEmptyUtm(utm) {
60
+ if (!utm)
61
+ return true;
62
+ const allUtmEmpty = UTM_STRING_KEYS.every((key) => normalizeUtmField(utm[key]) === '');
63
+ const enLinkEmpty = normalizeUtmField(utm.enLinkId) === '';
64
+ const enCampaignEmpty = normalizeEnCampaignId(utm.enCampaignId) === null;
65
+ return allUtmEmpty && enLinkEmpty && enCampaignEmpty;
66
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const utm_touchpoints_1 = require("./utm-touchpoints");
4
+ describe('normalizeUtmField', () => {
5
+ it('lowercases and trims', () => {
6
+ expect((0, utm_touchpoints_1.normalizeUtmField)(' Summer ')).toBe('summer');
7
+ expect((0, utm_touchpoints_1.normalizeUtmField)('FACEBOOK')).toBe('facebook');
8
+ });
9
+ it('returns empty string for null/undefined', () => {
10
+ expect((0, utm_touchpoints_1.normalizeUtmField)(null)).toBe('');
11
+ expect((0, utm_touchpoints_1.normalizeUtmField)(undefined)).toBe('');
12
+ });
13
+ it('returns empty string for whitespace-only', () => {
14
+ expect((0, utm_touchpoints_1.normalizeUtmField)(' ')).toBe('');
15
+ });
16
+ it('preserves non-trimmed inner content', () => {
17
+ expect((0, utm_touchpoints_1.normalizeUtmField)(' black friday 2024 ')).toBe('black friday 2024');
18
+ });
19
+ });
20
+ describe('normalizeEnCampaignId', () => {
21
+ it('keeps positive integers', () => {
22
+ expect((0, utm_touchpoints_1.normalizeEnCampaignId)(42)).toBe(42);
23
+ expect((0, utm_touchpoints_1.normalizeEnCampaignId)('42')).toBe(42);
24
+ });
25
+ it('returns null for non-positive / non-finite / empty', () => {
26
+ expect((0, utm_touchpoints_1.normalizeEnCampaignId)(null)).toBeNull();
27
+ expect((0, utm_touchpoints_1.normalizeEnCampaignId)(undefined)).toBeNull();
28
+ expect((0, utm_touchpoints_1.normalizeEnCampaignId)(0)).toBeNull();
29
+ expect((0, utm_touchpoints_1.normalizeEnCampaignId)(-1)).toBeNull();
30
+ expect((0, utm_touchpoints_1.normalizeEnCampaignId)('')).toBeNull();
31
+ expect((0, utm_touchpoints_1.normalizeEnCampaignId)('abc')).toBeNull();
32
+ expect((0, utm_touchpoints_1.normalizeEnCampaignId)(Number.NaN)).toBeNull();
33
+ });
34
+ it('truncates fractional part', () => {
35
+ expect((0, utm_touchpoints_1.normalizeEnCampaignId)(3.7)).toBe(3);
36
+ });
37
+ });
38
+ describe('normalizeUtm', () => {
39
+ it('produces 5 *Norm fields plus enLinkIdNorm and enCampaignId', () => {
40
+ const out = (0, utm_touchpoints_1.normalizeUtm)({
41
+ utmSource: ' Google ',
42
+ utmMedium: 'CPC',
43
+ utmCampaign: undefined,
44
+ utmContent: null,
45
+ utmTerm: 'BUY',
46
+ enCampaignId: 17,
47
+ enLinkId: ' Cta_Primary ',
48
+ });
49
+ expect(out).toEqual({
50
+ utmSourceNorm: 'google',
51
+ utmMediumNorm: 'cpc',
52
+ utmCampaignNorm: '',
53
+ utmContentNorm: '',
54
+ utmTermNorm: 'buy',
55
+ enLinkIdNorm: 'cta_primary',
56
+ enCampaignId: 17,
57
+ });
58
+ });
59
+ it('handles empty input', () => {
60
+ expect((0, utm_touchpoints_1.normalizeUtm)({})).toEqual({
61
+ utmSourceNorm: '',
62
+ utmMediumNorm: '',
63
+ utmCampaignNorm: '',
64
+ utmContentNorm: '',
65
+ utmTermNorm: '',
66
+ enLinkIdNorm: '',
67
+ enCampaignId: null,
68
+ });
69
+ expect((0, utm_touchpoints_1.normalizeUtm)(null)).toEqual({
70
+ utmSourceNorm: '',
71
+ utmMediumNorm: '',
72
+ utmCampaignNorm: '',
73
+ utmContentNorm: '',
74
+ utmTermNorm: '',
75
+ enLinkIdNorm: '',
76
+ enCampaignId: null,
77
+ });
78
+ });
79
+ });
80
+ describe('isEmptyUtm', () => {
81
+ it('true for null/undefined/empty', () => {
82
+ expect((0, utm_touchpoints_1.isEmptyUtm)(null)).toBe(true);
83
+ expect((0, utm_touchpoints_1.isEmptyUtm)(undefined)).toBe(true);
84
+ expect((0, utm_touchpoints_1.isEmptyUtm)({})).toBe(true);
85
+ expect((0, utm_touchpoints_1.isEmptyUtm)({ utmSource: ' ' })).toBe(true);
86
+ expect((0, utm_touchpoints_1.isEmptyUtm)({ utmCampaign: '' })).toBe(true);
87
+ expect((0, utm_touchpoints_1.isEmptyUtm)({ enCampaignId: 0, enLinkId: ' ' })).toBe(true);
88
+ });
89
+ it('false if any non-blank field present', () => {
90
+ expect((0, utm_touchpoints_1.isEmptyUtm)({ utmSource: 'google' })).toBe(false);
91
+ expect((0, utm_touchpoints_1.isEmptyUtm)({ utmCampaign: 'X', utmTerm: '' })).toBe(false);
92
+ expect((0, utm_touchpoints_1.isEmptyUtm)({ enCampaignId: 5 })).toBe(false);
93
+ expect((0, utm_touchpoints_1.isEmptyUtm)({ enLinkId: 'cta_primary' })).toBe(false);
94
+ });
95
+ });
@@ -1 +1 @@
1
- export declare function validateByDto(t: any): <T>(data: T) => void | Record<string, unknown>;
1
+ export declare function validateByDto(t: any): <T>(data: T) => undefined | Record<string, unknown>;