@escapenavigator/utils 1.10.150 → 1.10.151

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.
@@ -7,14 +7,14 @@
7
7
  * однотипные бизнес-инциденты.
8
8
  *
9
9
  * critical → level=error, tag severity=critical
10
- * 5xx, network/timeout онлайн, внутренние ошибки бэка
10
+ * 5xx, timeout онлайн, внутренние ошибки бэка
11
11
  * без бизнес-кода. Всегда баг — наш или upstream.
12
12
  *
13
13
  * business → level=warning, tag severity=business
14
14
  * 4xx с конкретным `errorCode` (SLOT_TAKEN, PROMO_INVALID,
15
- * ALREADY_PAID, ...). Не баг сам по себе, но если по одному
16
- * endpoint+code за день копится много событий — повод проверить
17
- * UX или валидацию на бэке.
15
+ * widgetUnavailable, orderFinalizedUseCancel, ...). Не баг
16
+ * сам по себе, но если по одному endpoint+code за день копится
17
+ * много событий — повод проверить UX или данные.
18
18
  *
19
19
  * debug → level=info, tag severity=debug
20
20
  * 4xx с generic кодом (BAD_REQUEST, INTERNAL_ERROR, DEFAULT)
@@ -22,8 +22,8 @@
22
22
  * полезно посмотреть.
23
23
  *
24
24
  * skip → не репортим
25
- * 401/403 (handled через onUnauthorized + редирект на /auth),
26
- * 304 (etag-success), отменённые axios-запросы (ERR_CANCELED).
25
+ * 304 (etag-success), отменённые axios-запросы (ERR_CANCELED),
26
+ * 401/403 на app/auth (ожидаемый session/role flow).
27
27
  *
28
28
  * Группировка (`fingerprint`) делается так, чтобы все события с одинаковым
29
29
  * `apiKey + errorCode` сливались в один issue. Иначе Sentry плодит отдельный
@@ -32,6 +32,8 @@
32
32
  */
33
33
  export type ApiErrorSeverity = 'critical' | 'business' | 'debug' | 'skip';
34
34
  export type SentryLevel = 'fatal' | 'error' | 'warning' | 'info' | 'debug' | 'log';
35
+ /** Какой фронт классифицирует ошибку — влияет на skip 401/403. */
36
+ export type ApiErrorApp = 'widget' | 'app' | 'auth' | 'orders';
35
37
  export type ApiErrorClassificationContext = {
36
38
  apiKey: string;
37
39
  method?: string;
@@ -51,6 +53,8 @@ export type ApiErrorClassificationContext = {
51
53
  errorCode?: string;
52
54
  /** Изначальный бизнес-код до маппинга (originalErrorCode) */
53
55
  originalErrorCode?: string;
56
+ /** Фронт-репортёр: влияет на skip 401/403 (см. ниже). */
57
+ app?: ApiErrorApp;
54
58
  };
55
59
  export type ApiErrorClassification = {
56
60
  severity: ApiErrorSeverity;
@@ -65,3 +69,7 @@ export type ApiErrorClassification = {
65
69
  shouldReport: boolean;
66
70
  };
67
71
  export declare function classifyApiError(ctx: ApiErrorClassificationContext): ApiErrorClassification;
72
+ /** Хелпер для beforeSend: сетевой/таймаутный шум при offline. */
73
+ export declare function isOfflineNetworkRelatedEvent(message: string, hint?: {
74
+ originalException?: unknown;
75
+ }): boolean;
@@ -8,14 +8,14 @@
8
8
  * однотипные бизнес-инциденты.
9
9
  *
10
10
  * critical → level=error, tag severity=critical
11
- * 5xx, network/timeout онлайн, внутренние ошибки бэка
11
+ * 5xx, timeout онлайн, внутренние ошибки бэка
12
12
  * без бизнес-кода. Всегда баг — наш или upstream.
13
13
  *
14
14
  * business → level=warning, tag severity=business
15
15
  * 4xx с конкретным `errorCode` (SLOT_TAKEN, PROMO_INVALID,
16
- * ALREADY_PAID, ...). Не баг сам по себе, но если по одному
17
- * endpoint+code за день копится много событий — повод проверить
18
- * UX или валидацию на бэке.
16
+ * widgetUnavailable, orderFinalizedUseCancel, ...). Не баг
17
+ * сам по себе, но если по одному endpoint+code за день копится
18
+ * много событий — повод проверить UX или данные.
19
19
  *
20
20
  * debug → level=info, tag severity=debug
21
21
  * 4xx с generic кодом (BAD_REQUEST, INTERNAL_ERROR, DEFAULT)
@@ -23,8 +23,8 @@
23
23
  * полезно посмотреть.
24
24
  *
25
25
  * skip → не репортим
26
- * 401/403 (handled через onUnauthorized + редирект на /auth),
27
- * 304 (etag-success), отменённые axios-запросы (ERR_CANCELED).
26
+ * 304 (etag-success), отменённые axios-запросы (ERR_CANCELED),
27
+ * 401/403 на app/auth (ожидаемый session/role flow).
28
28
  *
29
29
  * Группировка (`fingerprint`) делается так, чтобы все события с одинаковым
30
30
  * `apiKey + errorCode` сливались в один issue. Иначе Sentry плодит отдельный
@@ -33,6 +33,7 @@
33
33
  */
34
34
  Object.defineProperty(exports, "__esModule", { value: true });
35
35
  exports.classifyApiError = classifyApiError;
36
+ exports.isOfflineNetworkRelatedEvent = isOfflineNetworkRelatedEvent;
36
37
  /**
37
38
  * Бекенд маппит часть ошибок на эти generic-коды (см.
38
39
  * `packages/utils/src/get-service-error.ts:INTERNAL_SERVICE_ERROR_CODES`).
@@ -54,56 +55,36 @@ const GENERIC_ERROR_CODES = new Set([
54
55
  * lifecycle компонента.
55
56
  */
56
57
  const SKIP_AXIOS_CODES = new Set(['ERR_CANCELED', 'CANCELED']);
58
+ /** 304 = успешный ответ при if-none-match (etag). */
59
+ const SKIP_HTTP_STATUSES = new Set([304]);
57
60
  /**
58
- * 401 = expired session, у нас глобальный `onUnauthorized` редирект на
59
- * /auth. Sentry от такого не получает никакой полезной инфы.
60
- *
61
- * 403 = доступ закрыт. На CRM это тоже expected (роль пользователя), на
62
- * widget/orders просто не должно случаться — но если случилось, это
63
- * config issue, нам нужен бэк-лог, а не дубль на фронте.
64
- *
65
- * 304 = успешный ответ при if-none-match (etag), axios считает это
66
- * ошибкой только потому что код != 200.
61
+ * 401/403 на CRM/auth ожидаемый flow (протухшая сессия, роль пользователя).
62
+ * На widget/orders репортим как debug: это сигнал для мониторинга (битый
63
+ * bearer, неожиданный 403 на embed).
67
64
  */
68
- const SKIP_HTTP_STATUSES = new Set([401, 403, 304]);
69
- /**
70
- * Бизнес-коды 4xx, которые НЕ нужно репортить вообще: это ожидаемое
71
- * состояние, а не баг кода, и юзер уже видит понятное сообщение.
72
- *
73
- * widgetUnavailable — 404 на /openapi/widget/info для несуществующего /
74
- * удалённого/переименованного widgetUid. Это «протухший» embed-сниппет
75
- * на хост-сайте партнёра (его надо обновить — задача поддержки/данных,
76
- * не кода). Один битый embed генерил 145 событий / 97 юзеров в день
77
- * (ESCAPE-NAVIGATOR-WIDGET-E). Раньше эта же ситуация прилетала как 500
78
- * (WIDGET-7) — мы починили бэк на чистый 404, теперь глушим и фронт-репорт.
79
- *
80
- * orderFinalizedUseCancel — 400 на DELETE /openapi/orders/preorder, когда
81
- * юзер удаляет уже подтверждённую бронь (надо отменять, а не чистить
82
- * draft). Ожидаемое бизнес-состояние, юзер видит понятное сообщение
83
- * (ESCAPE-NAVIGATOR-WIDGET-B). Не баг кода — глушим фронт-репорт.
84
- */
85
- const SKIP_BUSINESS_CODES = new Set(['widgetUnavailable', 'orderFinalizedUseCancel']);
65
+ const SKIP_AUTH_STATUSES_ON_APPS = new Set(['app', 'auth']);
66
+ const isNetworkErrorMessage = (message) => typeof message === 'string' && /network error/i.test(message);
86
67
  function classifyApiError(ctx) {
87
- const { status, code, apiKey, errorCode, originalErrorCode, message } = ctx;
68
+ const { status, code, apiKey, errorCode, originalErrorCode, message, app } = ctx;
88
69
  if (code && SKIP_AXIOS_CODES.has(code)) {
89
70
  return { severity: 'skip', level: 'info', fingerprint: null, shouldReport: false };
90
71
  }
91
- // «timeout of 0ms exceeded» — это НЕ таймаут. У app/auth/orders timeout не
92
- // выставлен (0), поэтому axios не может сам сгенерировать таймаут. Такое
93
- // сообщение приходит, когда браузер сам обрывает in-flight XHR: Safari/iOS
94
- // гасит запросы при смене сети (Wi-Fi↔LTE) или уходе вкладки в фон и
95
- // сообщает об этом через ontimeout → axios лепит «0ms» с кодом
96
- // ECONNABORTED. Это user-environment, не баг — skip, как ERR_CANCELED.
97
- // Реальный таймаут виджета приходит как «timeout of 25000ms exceeded» и
98
- // под это условие не попадает (остаётся critical ниже).
72
+ // «timeout of 0ms exceeded» — Safari/iOS обрывает XHR при смене сети или
73
+ // уходе вкладки в фон. Не баг, но полезно для картины мобильной связности.
99
74
  if (code === 'ECONNABORTED' && typeof message === 'string' && /timeout of 0ms/i.test(message)) {
100
- return { severity: 'skip', level: 'info', fingerprint: null, shouldReport: false };
75
+ return {
76
+ severity: 'business',
77
+ level: 'info',
78
+ fingerprint: ['api', 'mobile-abort', apiKey],
79
+ shouldReport: true,
80
+ };
101
81
  }
102
82
  if (typeof status === 'number' && SKIP_HTTP_STATUSES.has(status)) {
103
83
  return { severity: 'skip', level: 'info', fingerprint: null, shouldReport: false };
104
84
  }
105
- if ((errorCode && SKIP_BUSINESS_CODES.has(errorCode)) ||
106
- (originalErrorCode && SKIP_BUSINESS_CODES.has(originalErrorCode))) {
85
+ if (typeof status === 'number' &&
86
+ (status === 401 || status === 403) &&
87
+ (!app || SKIP_AUTH_STATUSES_ON_APPS.has(app))) {
107
88
  return { severity: 'skip', level: 'info', fingerprint: null, shouldReport: false };
108
89
  }
109
90
  // 5xx → critical. Включаем status в fingerprint, чтобы 502/503/504 не
@@ -129,7 +110,7 @@ function classifyApiError(ctx) {
129
110
  };
130
111
  }
131
112
  // 4xx → business либо debug. Решаем по errorCode из тела ответа:
132
- // - конкретный бизнес-код (SLOT_TAKEN, PROMO_INVALID, ...) → business
113
+ // - конкретный бизнес-код (SLOT_TAKEN, widgetUnavailable, ...) → business
133
114
  // - generic (BAD_REQUEST, INTERNAL_ERROR) или его отсутствие → debug
134
115
  //
135
116
  // Фингерпринт по `apiKey + errorCode`: все события «createPreorder
@@ -152,9 +133,24 @@ function classifyApiError(ctx) {
152
133
  shouldReport: true,
153
134
  };
154
135
  }
155
- // Сюда падают: NETWORK_ERROR без axios-кода, какие-то странные кейсы
156
- // без status. Считаем critical, но без агрессивного группирования
157
- // пусть Sentry разберётся по stacktrace.
136
+ // Чистый network error: ответа от сервера нет (`!status`), axios-кода
137
+ // таймаута тоже нет, но message === 'Network Error' (axios@0.21) либо
138
+ // code === 'ERR_NETWORK' (axios>=0.27). На проде это почти всегда
139
+ // клиентская связность (мобильный обрыв, смена соты, троттлинг/RKN).
140
+ // На виджете GET'ы уже ретраятся — сюда долетает только финальный фейл.
141
+ const isNetworkError = !status &&
142
+ (code === 'ERR_NETWORK' || (!code && isNetworkErrorMessage(message)));
143
+ if (isNetworkError) {
144
+ return {
145
+ severity: 'business',
146
+ level: 'warning',
147
+ fingerprint: ['api', 'network', apiKey],
148
+ shouldReport: true,
149
+ };
150
+ }
151
+ // Сюда падают: какие-то странные кейсы без status/кода/сетевого message.
152
+ // Считаем critical, но без агрессивного группирования — пусть Sentry
153
+ // разберётся по stacktrace.
158
154
  return {
159
155
  severity: 'critical',
160
156
  level: 'error',
@@ -162,3 +158,19 @@ function classifyApiError(ctx) {
162
158
  shouldReport: true,
163
159
  };
164
160
  }
161
+ /** Хелпер для beforeSend: сетевой/таймаутный шум при offline. */
162
+ function isOfflineNetworkRelatedEvent(message, hint) {
163
+ if (typeof message === 'string' && /network error/i.test(message))
164
+ return true;
165
+ if (typeof message === 'string' && /timeout/i.test(message))
166
+ return true;
167
+ const original = hint?.originalException;
168
+ if (original?.code === 'NETWORK_ERROR' || original?.errorType === 'NETWORK')
169
+ return true;
170
+ if (original?.code === 'ECONNABORTED' || original?.code === 'ERR_NETWORK')
171
+ return true;
172
+ if (typeof original?.message === 'string' && /network error|timeout/i.test(original.message)) {
173
+ return true;
174
+ }
175
+ return false;
176
+ }
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@ export * from './get-order-cancelation-state';
15
15
  export * from './get-service-error';
16
16
  export * from './is-axios-error';
17
17
  export * from './is-network-error';
18
+ export * from './phone';
18
19
  export * from './pick';
19
20
  export * from './promocode-error-codes';
20
21
  export * from './promocode-nominal-rules';
package/dist/index.js CHANGED
@@ -31,6 +31,7 @@ __exportStar(require("./get-order-cancelation-state"), exports);
31
31
  __exportStar(require("./get-service-error"), exports);
32
32
  __exportStar(require("./is-axios-error"), exports);
33
33
  __exportStar(require("./is-network-error"), exports);
34
+ __exportStar(require("./phone"), exports);
34
35
  __exportStar(require("./pick"), exports);
35
36
  __exportStar(require("./promocode-error-codes"), exports);
36
37
  __exportStar(require("./promocode-nominal-rules"), exports);
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Best-effort country guess from the visitor browser.
3
+ * Uses navigator.languages first, then navigator.language.
4
+ */
5
+ export declare function getBrowserPhoneCountry(): string | null;
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getBrowserPhoneCountry = getBrowserPhoneCountry;
4
+ const map_country_to_phone_input_1 = require("./map-country-to-phone-input");
5
+ const LOCALE_LANGUAGE_FALLBACK = {
6
+ ru: 'ru',
7
+ en: 'us',
8
+ de: 'de',
9
+ fr: 'fr',
10
+ es: 'es',
11
+ it: 'it',
12
+ pt: 'pt',
13
+ pl: 'pl',
14
+ uk: 'gb',
15
+ };
16
+ function regionFromLocaleTag(tag) {
17
+ const normalized = tag.trim().replace(/_/g, '-');
18
+ if (!normalized) {
19
+ return null;
20
+ }
21
+ try {
22
+ const locale = new Intl.Locale(normalized);
23
+ const region = locale.region?.toLowerCase();
24
+ if (region) {
25
+ return (0, map_country_to_phone_input_1.mapCountryToPhoneInput)(region);
26
+ }
27
+ }
28
+ catch {
29
+ // Intl.Locale is unavailable or tag is invalid — fall through.
30
+ }
31
+ const parts = normalized.split('-');
32
+ if (parts.length >= 2) {
33
+ return (0, map_country_to_phone_input_1.mapCountryToPhoneInput)(parts[parts.length - 1]);
34
+ }
35
+ return LOCALE_LANGUAGE_FALLBACK[parts[0].toLowerCase()] ?? null;
36
+ }
37
+ /**
38
+ * Best-effort country guess from the visitor browser.
39
+ * Uses navigator.languages first, then navigator.language.
40
+ */
41
+ function getBrowserPhoneCountry() {
42
+ if (typeof navigator === 'undefined') {
43
+ return null;
44
+ }
45
+ const candidates = [
46
+ ...(navigator.languages ?? []),
47
+ navigator.language,
48
+ ].filter(Boolean);
49
+ for (const candidate of candidates) {
50
+ const country = regionFromLocaleTag(candidate);
51
+ if (country) {
52
+ return country;
53
+ }
54
+ }
55
+ return null;
56
+ }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Extracts a react-phone-input-2 country code from an E.164-ish phone string.
3
+ */
4
+ export declare function getCountryFromPhone(phone: string): string | null;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getCountryFromPhone = getCountryFromPhone;
4
+ const libphonenumber_js_1 = require("libphonenumber-js");
5
+ const map_country_to_phone_input_1 = require("./map-country-to-phone-input");
6
+ /**
7
+ * Extracts a react-phone-input-2 country code from an E.164-ish phone string.
8
+ */
9
+ function getCountryFromPhone(phone) {
10
+ const trimmed = phone?.trim();
11
+ if (!trimmed) {
12
+ return null;
13
+ }
14
+ try {
15
+ const parsed = (0, libphonenumber_js_1.parsePhoneNumberWithError)(trimmed);
16
+ const country = parsed.country;
17
+ if (!country) {
18
+ return null;
19
+ }
20
+ return (0, map_country_to_phone_input_1.mapCountryToPhoneInput)(country);
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
@@ -0,0 +1,5 @@
1
+ export * from './get-browser-phone-country';
2
+ export * from './get-country-from-phone';
3
+ export * from './map-country-to-phone-input';
4
+ export * from './resolve-phone-country';
5
+ export * from './validate-phone-number';
@@ -0,0 +1,21 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./get-browser-phone-country"), exports);
18
+ __exportStar(require("./get-country-from-phone"), exports);
19
+ __exportStar(require("./map-country-to-phone-input"), exports);
20
+ __exportStar(require("./resolve-phone-country"), exports);
21
+ __exportStar(require("./validate-phone-number"), exports);
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Maps CountriesEnum / internal country codes to react-phone-input-2 codes.
3
+ * UK is stored as `uk` in our domain model but the picker expects `gb`.
4
+ */
5
+ export declare function mapCountryToPhoneInput(country: string): string;
@@ -0,0 +1,14 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mapCountryToPhoneInput = mapCountryToPhoneInput;
4
+ /**
5
+ * Maps CountriesEnum / internal country codes to react-phone-input-2 codes.
6
+ * UK is stored as `uk` in our domain model but the picker expects `gb`.
7
+ */
8
+ function mapCountryToPhoneInput(country) {
9
+ const normalized = country.trim().toLowerCase();
10
+ if (normalized === 'uk') {
11
+ return 'gb';
12
+ }
13
+ return normalized;
14
+ }
@@ -0,0 +1,20 @@
1
+ export type ResolvePhoneCountryOptions = {
2
+ /** Existing phone — highest priority when editing a saved number. */
3
+ phone?: string | null;
4
+ /** Organization / widget profile country — best default for new bookings. */
5
+ profileCountry?: string | null;
6
+ /** i18n.language or another BCP-47 tag — fallback when browser region is missing. */
7
+ locale?: string | null;
8
+ fallback?: string;
9
+ };
10
+ /**
11
+ * Resolves the default country for react-phone-input-2.
12
+ *
13
+ * Priority:
14
+ * 1. Country parsed from an existing phone number
15
+ * 2. Business profile country (questroom / organization)
16
+ * 3. Browser locale region (navigator.languages)
17
+ * 4. Explicit UI locale (i18n.language)
18
+ * 5. fallback (`us` by default)
19
+ */
20
+ export declare function resolvePhoneCountry({ phone, profileCountry, locale, fallback, }: ResolvePhoneCountryOptions): string;
@@ -0,0 +1,52 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolvePhoneCountry = resolvePhoneCountry;
4
+ const get_browser_phone_country_1 = require("./get-browser-phone-country");
5
+ const get_country_from_phone_1 = require("./get-country-from-phone");
6
+ const map_country_to_phone_input_1 = require("./map-country-to-phone-input");
7
+ function getCountryFromLocale(locale) {
8
+ const normalized = locale.trim().replace(/_/g, '-');
9
+ const parts = normalized.split('-');
10
+ if (parts.length >= 2) {
11
+ return (0, map_country_to_phone_input_1.mapCountryToPhoneInput)(parts[parts.length - 1]);
12
+ }
13
+ if (parts[0].toLowerCase() === 'ru') {
14
+ return 'ru';
15
+ }
16
+ if (parts[0].toLowerCase() === 'en') {
17
+ return 'us';
18
+ }
19
+ return (0, map_country_to_phone_input_1.mapCountryToPhoneInput)(parts[0]);
20
+ }
21
+ /**
22
+ * Resolves the default country for react-phone-input-2.
23
+ *
24
+ * Priority:
25
+ * 1. Country parsed from an existing phone number
26
+ * 2. Business profile country (questroom / organization)
27
+ * 3. Browser locale region (navigator.languages)
28
+ * 4. Explicit UI locale (i18n.language)
29
+ * 5. fallback (`us` by default)
30
+ */
31
+ function resolvePhoneCountry({ phone, profileCountry, locale, fallback = 'us', }) {
32
+ if (phone?.trim()) {
33
+ const fromPhone = (0, get_country_from_phone_1.getCountryFromPhone)(phone);
34
+ if (fromPhone) {
35
+ return fromPhone;
36
+ }
37
+ }
38
+ if (profileCountry?.trim()) {
39
+ return (0, map_country_to_phone_input_1.mapCountryToPhoneInput)(profileCountry);
40
+ }
41
+ const fromBrowser = (0, get_browser_phone_country_1.getBrowserPhoneCountry)();
42
+ if (fromBrowser) {
43
+ return fromBrowser;
44
+ }
45
+ if (locale?.trim()) {
46
+ const fromLocale = getCountryFromLocale(locale);
47
+ if (fromLocale) {
48
+ return fromLocale;
49
+ }
50
+ }
51
+ return (0, map_country_to_phone_input_1.mapCountryToPhoneInput)(fallback);
52
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const resolve_phone_country_1 = require("./resolve-phone-country");
4
+ describe('resolvePhoneCountry', () => {
5
+ const originalNavigator = global.navigator;
6
+ afterEach(() => {
7
+ Object.defineProperty(global, 'navigator', {
8
+ configurable: true,
9
+ value: originalNavigator,
10
+ });
11
+ });
12
+ it('prefers country parsed from an existing phone', () => {
13
+ expect((0, resolve_phone_country_1.resolvePhoneCountry)({
14
+ phone: '+37120000000',
15
+ profileCountry: 'ru',
16
+ locale: 'ru-RU',
17
+ })).toBe('lv');
18
+ });
19
+ it('uses profile country for empty phone', () => {
20
+ expect((0, resolve_phone_country_1.resolvePhoneCountry)({
21
+ profileCountry: 'uk',
22
+ })).toBe('gb');
23
+ });
24
+ it('maps UK enum value to gb', () => {
25
+ expect((0, resolve_phone_country_1.resolvePhoneCountry)({
26
+ profileCountry: 'UK',
27
+ })).toBe('gb');
28
+ });
29
+ it('falls back to browser locale region', () => {
30
+ Object.defineProperty(global, 'navigator', {
31
+ configurable: true,
32
+ value: {
33
+ language: 'de-DE',
34
+ languages: ['de-DE'],
35
+ },
36
+ });
37
+ expect((0, resolve_phone_country_1.resolvePhoneCountry)({})).toBe('de');
38
+ });
39
+ it('falls back to explicit locale when browser region is unknown', () => {
40
+ Object.defineProperty(global, 'navigator', {
41
+ configurable: true,
42
+ value: {
43
+ language: 'xx',
44
+ languages: ['xx'],
45
+ },
46
+ });
47
+ expect((0, resolve_phone_country_1.resolvePhoneCountry)({ locale: 'ru-RU' })).toBe('ru');
48
+ });
49
+ });
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Shared phone validation used by all frontends.
3
+ * Soft exceptions for Sweden/UK are kept for historical false negatives.
4
+ */
5
+ export declare function validatePhoneNumber(phone: string): boolean;
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validatePhoneNumber = validatePhoneNumber;
4
+ const libphonenumber_js_1 = require("libphonenumber-js");
5
+ /**
6
+ * Shared phone validation used by all frontends.
7
+ * Soft exceptions for Sweden/UK are kept for historical false negatives.
8
+ */
9
+ function validatePhoneNumber(phone) {
10
+ if (!phone?.trim()) {
11
+ return false;
12
+ }
13
+ if (phone.includes('+46') && phone.length === 13) {
14
+ return true;
15
+ }
16
+ if (phone.includes('+44') && phone.length === 13) {
17
+ return true;
18
+ }
19
+ try {
20
+ return (0, libphonenumber_js_1.isValidPhoneNumber)(phone);
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@escapenavigator/utils",
3
- "version": "1.10.150",
3
+ "version": "1.10.151",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -14,11 +14,12 @@
14
14
  "test": "jest"
15
15
  },
16
16
  "dependencies": {
17
- "@escapenavigator/types": "^1.10.145",
17
+ "@escapenavigator/types": "^1.10.146",
18
18
  "axios": "^0.21.4",
19
19
  "class-transformer": "^0.5.1",
20
20
  "class-validator": "^0.13.2",
21
21
  "i18next": "^21.6.4",
22
+ "libphonenumber-js": "^1.12.24",
22
23
  "quill-delta": "^5.1.0"
23
24
  },
24
25
  "devDependencies": {
@@ -27,5 +28,5 @@
27
28
  "ts-jest": "^29.1.1",
28
29
  "typescript": "^5.6"
29
30
  },
30
- "gitHead": "a544fe463f486e809bc002494b1260c6bb596aaf"
31
+ "gitHead": "b7cc49e197a4d4cda6337a2595d620dd88b7bd89"
31
32
  }