@escapenavigator/utils 1.10.150 → 1.10.152
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/classify-api-error.d.ts +14 -6
- package/dist/classify-api-error.js +60 -49
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/phone/get-browser-phone-country.d.ts +5 -0
- package/dist/phone/get-browser-phone-country.js +53 -0
- package/dist/phone/get-country-from-phone.d.ts +4 -0
- package/dist/phone/get-country-from-phone.js +25 -0
- package/dist/phone/index.d.ts +5 -0
- package/dist/phone/index.js +21 -0
- package/dist/phone/map-country-to-phone-input.d.ts +5 -0
- package/dist/phone/map-country-to-phone-input.js +14 -0
- package/dist/phone/resolve-phone-country.d.ts +20 -0
- package/dist/phone/resolve-phone-country.js +52 -0
- package/dist/phone/resolve-phone-country.spec.d.ts +1 -0
- package/dist/phone/resolve-phone-country.spec.js +49 -0
- package/dist/phone/validate-phone-number.d.ts +5 -0
- package/dist/phone/validate-phone-number.js +25 -0
- package/package.json +4 -3
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
* однотипные бизнес-инциденты.
|
|
8
8
|
*
|
|
9
9
|
* critical → level=error, tag severity=critical
|
|
10
|
-
* 5xx,
|
|
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
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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,
|
|
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
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
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
|
|
59
|
-
* /
|
|
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
|
|
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» —
|
|
92
|
-
//
|
|
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 {
|
|
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 (
|
|
106
|
-
(
|
|
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,
|
|
113
|
+
// - конкретный бизнес-код (SLOT_TAKEN, widgetUnavailable, ...) → business
|
|
133
114
|
// - generic (BAD_REQUEST, INTERNAL_ERROR) или его отсутствие → debug
|
|
134
115
|
//
|
|
135
116
|
// Фингерпринт по `apiKey + errorCode`: все события «createPreorder
|
|
@@ -152,9 +133,23 @@ function classifyApiError(ctx) {
|
|
|
152
133
|
shouldReport: true,
|
|
153
134
|
};
|
|
154
135
|
}
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
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 && (code === 'ERR_NETWORK' || (!code && isNetworkErrorMessage(message)));
|
|
142
|
+
if (isNetworkError) {
|
|
143
|
+
return {
|
|
144
|
+
severity: 'business',
|
|
145
|
+
level: 'warning',
|
|
146
|
+
fingerprint: ['api', 'network', apiKey],
|
|
147
|
+
shouldReport: true,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// Сюда падают: какие-то странные кейсы без status/кода/сетевого message.
|
|
151
|
+
// Считаем critical, но без агрессивного группирования — пусть Sentry
|
|
152
|
+
// разберётся по stacktrace.
|
|
158
153
|
return {
|
|
159
154
|
severity: 'critical',
|
|
160
155
|
level: 'error',
|
|
@@ -162,3 +157,19 @@ function classifyApiError(ctx) {
|
|
|
162
157
|
shouldReport: true,
|
|
163
158
|
};
|
|
164
159
|
}
|
|
160
|
+
/** Хелпер для beforeSend: сетевой/таймаутный шум при offline. */
|
|
161
|
+
function isOfflineNetworkRelatedEvent(message, hint) {
|
|
162
|
+
if (typeof message === 'string' && /network error/i.test(message))
|
|
163
|
+
return true;
|
|
164
|
+
if (typeof message === 'string' && /timeout/i.test(message))
|
|
165
|
+
return true;
|
|
166
|
+
const original = hint?.originalException;
|
|
167
|
+
if (original?.code === 'NETWORK_ERROR' || original?.errorType === 'NETWORK')
|
|
168
|
+
return true;
|
|
169
|
+
if (original?.code === 'ECONNABORTED' || original?.code === 'ERR_NETWORK')
|
|
170
|
+
return true;
|
|
171
|
+
if (typeof original?.message === 'string' && /network error|timeout/i.test(original.message)) {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
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,53 @@
|
|
|
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 = [...(navigator.languages ?? []), navigator.language].filter(Boolean);
|
|
46
|
+
for (const candidate of candidates) {
|
|
47
|
+
const country = regionFromLocaleTag(candidate);
|
|
48
|
+
if (country) {
|
|
49
|
+
return country;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
@@ -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,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,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,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.
|
|
3
|
+
"version": "1.10.152",
|
|
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.
|
|
17
|
+
"@escapenavigator/types": "^1.10.147",
|
|
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": "
|
|
31
|
+
"gitHead": "ad97abd6bd58ad005be9ec0951a78db2c83a97f3"
|
|
31
32
|
}
|