mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-10-31 23:20:48 +00:00
Compare commits
72 Commits
a6a7e9fb5c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4877965a1 | ||
|
|
2192484b01 | ||
|
|
8a7ef9ace0 | ||
|
|
6474b28cef | ||
|
|
a451a129fe | ||
|
|
5c3031f5f5 | ||
|
|
5db1ffccf6 | ||
|
|
debe26ae35 | ||
|
|
28e25a9292 | ||
|
|
b4e1de6152 | ||
|
|
f81af401f7 | ||
|
|
acbffa4e17 | ||
|
|
8d0c33bb08 | ||
|
|
cb309ead51 | ||
|
|
714ece8250 | ||
|
|
eede5ffdfd | ||
|
|
8e9bb0dc23 | ||
|
|
cfc2ab2f7f | ||
|
|
2ba6fc0f6f | ||
|
|
d4379fce25 | ||
|
|
893b4020bd | ||
|
|
533fc3073f | ||
|
|
a82950126e | ||
|
|
d88928912a | ||
|
|
9a12e002fd | ||
|
|
39b9b8e1d2 | ||
|
|
9532819fc6 | ||
|
|
3995ed8a28 | ||
|
|
29c9ed1a40 | ||
|
|
1883ec9340 | ||
|
|
04d3990749 | ||
|
|
415a76f7cf | ||
|
|
fa6b57b6ed | ||
|
|
02b3ffd4a0 | ||
|
|
c1088325aa | ||
|
|
76e10cc06c | ||
|
|
fdaf03feff | ||
|
|
2d6f8d9754 | ||
|
|
b3a3dc4ff4 | ||
|
|
06c7d13883 | ||
|
|
f6a64bfc1e | ||
|
|
eafc563128 | ||
|
|
e50b10a433 | ||
|
|
596b39b765 | ||
|
|
75424dba47 | ||
|
|
d03b9bdad7 | ||
|
|
b866bebeeb | ||
|
|
4c8e17a5a4 | ||
|
|
c718c4ca34 | ||
|
|
e840ed2b60 | ||
|
|
89a9f9f70c | ||
|
|
04c44c52de | ||
|
|
653d683835 | ||
|
|
0c1510fbfa | ||
|
|
602b3dd1b8 | ||
|
|
456cc2022b | ||
|
|
0f21629ba5 | ||
|
|
9a6aa1e508 | ||
|
|
b615927283 | ||
|
|
0e4dc43335 | ||
|
|
390da33914 | ||
|
|
c9afea7a50 | ||
|
|
a28f1167f7 | ||
|
|
2dce539da2 | ||
|
|
62aaa5d7c9 | ||
|
|
2f04f29b68 | ||
|
|
516399cef9 | ||
|
|
73cf2e265d | ||
|
|
2e3c4ba930 | ||
|
|
c6d8c0afe0 | ||
|
|
888a02f397 | ||
|
|
fb1f5cf3a7 |
67
CHANGELOG.md
67
CHANGELOG.md
@@ -1,5 +1,72 @@
|
||||
# Changelog
|
||||
|
||||
## [1.66.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.65.0...v1.66.0) (2026-08-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** allow manual opt-in for deep-link Telegram login ([d03b9bd](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d03b9bdad785ac7710f30527f230eeb793e1089b))
|
||||
* **dashboard:** плитка «Подключить устройство» на главной ([06c7d13](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/06c7d1388308de5fc1adad0cd2697d3c03c41c49))
|
||||
* **dashboard:** подключение устройства прикреплено к карточке подписки ([28e25a9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/28e25a92922501ed609623776e987fcd96f71032))
|
||||
* **landing:** автозаполнение поля контакта из параметра URL (?contact=...) ([8d0c33b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8d0c33bb08b8f28b19367d1ea612a8455fba0ff1))
|
||||
* **landing:** отправлять слаг рекламной кампании при гостевой покупке ([cfc2ab2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/cfc2ab2f7fe641570a818d0ca75c9cd9dddbf2b7))
|
||||
* **landing:** отправлять слаг рекламной кампании при гостевой покупке ([d4379fc](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d4379fce25af69e866e9232a3cd036652e7eae17))
|
||||
* **promocodes:** трафик в наборе бонусов промокода ([f6a64bf](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f6a64bfc1eb885cde31bcbab1f1474b1d693e718))
|
||||
* **remnawave:** GeoCheck ноды в админке — запуск проверки и просмотр отчёта ([b4e1de6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/b4e1de6152cfc85418c62e4f840180411989a2a7))
|
||||
* **users:** кнопка удаления подписки в карточке пользователя ([eafc563](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/eafc563128b68f49f0a1f03a30c899b527ce4734))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** дополнить тип sort_by и китайскую строку сортировки ([2d6f8d9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2d6f8d97546f7ca69540924aa0637f720500bef3))
|
||||
* **auth:** вернуть виджет после «Назад» и добить локали до четырёх ([c108832](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c1088325aabaf43de6f60653ff650c3cfb1beb2f))
|
||||
* **auth:** читать initData из моста Telegram, а не только из кэша SDK ([f81af40](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f81af401f7f6ca1a28806cbf237a33da7449d893))
|
||||
* **dashboard:** счётчик устройств в плитке на главной ([39b9b8e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/39b9b8e1d2690c894ce08b0ce16d4780ecffee09))
|
||||
* **geocheck:** сквозные клики через портал и safe-зоны в полноэкранном режиме ([debe26a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/debe26ae359545fead38bf087b069e9defecec26))
|
||||
* **geocheck:** скелетон до отрисовки отчёта и сброс зума на новом отчёте ([5c3031f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5c3031f5f52e4a7bbe101441a97f5e47dd1eff3f))
|
||||
* **geocheck:** убрать полноэкранный режим в Telegram Mini App ([5db1ffc](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5db1ffccf6a6659c06a1fbe5b2b703abd0c609bd))
|
||||
* **geocheck:** убрать скачивание отчёта в Telegram Mini App ([a451a12](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a451a129fe0b211369f336f09f8c8e1abd105173))
|
||||
* **landing:** не оставлять контакт из ссылки в адресной строке ([acbffa4](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/acbffa4e17e4549a1068f286e3009b38d30ae6be))
|
||||
* **promocodes:** чекбоксы набора по значениям, трафик в списке ([29c9ed1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/29c9ed1a40bad95b187fabd81098a795d4c096da))
|
||||
* **support:** показывать пользователю отказ бэка при создании тикета ([d889289](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d88928912acaaf9eeee52235ee4e2aace5e245e7))
|
||||
* **tickets:** длинный текст в сообщении уезжал за пределы карточки ([eede5ff](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/eede5ffdfdde9ece9956817c646ade36a9b2ccf6))
|
||||
* **tickets:** длинный текст в сообщении уезжал за пределы карточки ([2ba6fc0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2ba6fc0f6f743b8648c0d1e708cd123a465ee77f))
|
||||
* **users:** показывать причину отказа при удалении подписки ([415a76f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/415a76f7cff4e0e06edb15b91a24467f0465e2e9))
|
||||
|
||||
## [1.65.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.64.0...v1.65.0) (2026-08-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **admin:** панельная идентичность под Remnawave 3.0.0 ([e840ed2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e840ed2b60f5e525cb290ce5bddc70ef6dfe8a66))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **settings:** вернуть в админку категории, которых не было в дереве ([653d683](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/653d683835e2c10f7690f1f71df3552c3ba9ab2f))
|
||||
|
||||
## [1.64.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.63.0...v1.64.0) (2026-07-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **auth:** чекбоксы согласия с офертой и политикой при первой авторизации ([0f21629](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/0f21629ba516118790c5d7f65df18f485dfaeafe))
|
||||
* **branding:** загрузка видео стартового меню бота ([c9afea7](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c9afea7a5043e2a3b02d2242a116878fc70a0be6))
|
||||
* **coupons:** лимит на пользователя и удаление партии в кабинете ([390da33](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/390da33914eb3f992719e33acf85decac660a880))
|
||||
* **gift:** QR подарка и сканирование в кабинете ([0e4dc43](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/0e4dc43335135fe229ce3c695fd7fa2722eb87e4))
|
||||
* **lava:** автопродление Lava на странице подписки ([2dce539](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2dce539da2a7c3128084ed2bc6bcdf560d440c75))
|
||||
* **lava:** покупка привязкой, продукт тарифа в админке, CTA ([a28f116](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a28f1167f7af57bfc6e5c7c370849e7478ec459f))
|
||||
* **promo-offers:** охват сегментов и прогресс доставки на странице отправки ([c6d8c0a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c6d8c0afe0a30b864a9d0834735fdc7b52d8f362))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **admin:** фиксированный телеграм-синий у свотча «Синий» вместо акцента темы ([62aaa5d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/62aaa5d7c9eaedb5a118469587a35585f9e4f549))
|
||||
* **i18n:** починка плейсхолдеров и переводов в zh/fa/en локалях кабинета ([fb1f5cf](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/fb1f5cf3a762f55f18806be97a0db973316ff6ad))
|
||||
* **i18n:** существующие ключи вместо отсутствующих common.units.* на экране продления ([516399c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/516399cef9fa4a100b5dae979a04fafe0d05e634))
|
||||
* **security:** SRI для CDN-скрипта сканера QR ([b615927](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/b615927283d9ae530c03f6120b3c5794d92e0689))
|
||||
* **subscription:** корректная цена за месяц при выборе периода ([888a02f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/888a02f39722c68e4beddefbd1e39c402dfa51cb))
|
||||
|
||||
## [1.63.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.62.0...v1.63.0) (2026-07-24)
|
||||
|
||||
|
||||
|
||||
19
package-lock.json
generated
19
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cabinet-frontend",
|
||||
"version": "1.63.0",
|
||||
"version": "1.66.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cabinet-frontend",
|
||||
"version": "1.63.0",
|
||||
"version": "1.66.0",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -59,6 +59,7 @@
|
||||
"react-icons": "^5.6.0",
|
||||
"react-router": "^7.13.0",
|
||||
"react-twemoji": "^0.7.2",
|
||||
"react-zoom-pan-pinch": "^4.0.4",
|
||||
"recharts": "^3.7.0",
|
||||
"sigma": "^3.0.2",
|
||||
"simplex-noise": "^4.0.3",
|
||||
@@ -6211,6 +6212,20 @@
|
||||
"react-dom": ">=19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-zoom-pan-pinch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-zoom-pan-pinch/-/react-zoom-pan-pinch-4.0.4.tgz",
|
||||
"integrity": "sha512-P0D7lfNHyJCNuUozoVdt0WNWcQ34ZbbD71B8pb+UtF7Th8MKBnxYiPApDfPZZmr+Gk0l0WmGTXL8R36JirHcQQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8",
|
||||
"npm": ">=5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
"react-dom": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cabinet-frontend",
|
||||
"private": true,
|
||||
"version": "1.63.0",
|
||||
"version": "1.66.0",
|
||||
"description": "Remnawave Bedolaga Web App",
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
@@ -71,6 +71,7 @@
|
||||
"react-icons": "^5.6.0",
|
||||
"react-router": "^7.13.0",
|
||||
"react-twemoji": "^0.7.2",
|
||||
"react-zoom-pan-pinch": "^4.0.4",
|
||||
"recharts": "^3.7.0",
|
||||
"sigma": "^3.0.2",
|
||||
"simplex-noise": "^4.0.3",
|
||||
|
||||
@@ -112,6 +112,23 @@ export interface SystemStatsResponse {
|
||||
}
|
||||
|
||||
// Nodes
|
||||
export type NodeIpStatus =
|
||||
| 'INBOUND'
|
||||
| 'OUTBOUND'
|
||||
| 'MANAGEMENT'
|
||||
| 'TRANSIT'
|
||||
| 'MONITORING'
|
||||
| 'RESERVE'
|
||||
| 'BLOCKED'
|
||||
| 'FLAGGED'
|
||||
| 'DEPRECATED'
|
||||
| 'UNKNOWN';
|
||||
|
||||
export interface NodeIpAddress {
|
||||
ip: string;
|
||||
status: NodeIpStatus;
|
||||
}
|
||||
|
||||
export interface NodeInfo {
|
||||
uuid: string;
|
||||
name: string;
|
||||
@@ -165,6 +182,8 @@ export interface NodeInfo {
|
||||
};
|
||||
} | null;
|
||||
active_plugin_uuid?: string;
|
||||
/** Адреса узла из панели — варианты исходного адреса для GeoCheck. */
|
||||
ips?: NodeIpAddress[];
|
||||
config_profile?: {
|
||||
active_config_profile_uuid: string | null;
|
||||
active_inbounds: Array<{
|
||||
@@ -206,6 +225,40 @@ export interface NodeActionResponse {
|
||||
is_disabled?: boolean;
|
||||
}
|
||||
|
||||
// GeoCheck (Remnawave 3.3.0)
|
||||
/** С какого маршрута гнать проверку: пусто — маршрут узла по умолчанию. */
|
||||
export interface GeoCheckRequest {
|
||||
ip?: string;
|
||||
interface?: string;
|
||||
}
|
||||
|
||||
export interface GeoCheckStartResponse {
|
||||
job_id: string;
|
||||
}
|
||||
|
||||
/** SVG-отчёт в base64, готовый для data: URL. */
|
||||
export interface GeoCheckImage {
|
||||
format: string;
|
||||
media_type: string;
|
||||
encoding: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface GeoCheckResult {
|
||||
success: boolean;
|
||||
node_uuid?: string | null;
|
||||
image?: GeoCheckImage | null;
|
||||
raw_report?: Record<string, unknown> | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface GeoCheckJobResponse {
|
||||
job_id: string;
|
||||
is_completed: boolean;
|
||||
is_failed: boolean;
|
||||
result?: GeoCheckResult | null;
|
||||
}
|
||||
|
||||
// Realtime Traffic
|
||||
export interface InboundTraffic {
|
||||
tag: string;
|
||||
@@ -403,6 +456,21 @@ export const adminRemnawaveApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** Ставит GeoCheck ноды в очередь; результат забирается по job_id. */
|
||||
startNodeGeoCheck: async (
|
||||
uuid: string,
|
||||
body: GeoCheckRequest = {},
|
||||
): Promise<GeoCheckStartResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/geocheck`, body);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** Статус задачи GeoCheck — нода отвечает до минуты. */
|
||||
getGeoCheckJob: async (jobId: string): Promise<GeoCheckJobResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/geocheck/${jobId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
restartAllNodes: async (): Promise<NodeActionResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all');
|
||||
return response.data;
|
||||
|
||||
@@ -142,7 +142,7 @@ export interface UserDetailResponse {
|
||||
promo_offer_discount_source: string | null;
|
||||
promo_offer_discount_expires_at: string | null;
|
||||
recent_transactions: UserTransactionItem[];
|
||||
remnawave_uuid: string | null;
|
||||
remnawave_id: number | null;
|
||||
}
|
||||
|
||||
export interface UserPanelInfo {
|
||||
@@ -271,7 +271,7 @@ export interface UserAvailableTariffsResponse {
|
||||
|
||||
// Sync types
|
||||
export interface PanelUserInfo {
|
||||
uuid: string | null;
|
||||
id: number;
|
||||
short_uuid: string | null;
|
||||
username: string | null;
|
||||
status: string | null;
|
||||
@@ -295,7 +295,7 @@ export interface SyncToPanelResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
action: string;
|
||||
panel_uuid: string | null;
|
||||
panel_user_id: number | null;
|
||||
changes: Record<string, unknown>;
|
||||
errors: string[];
|
||||
}
|
||||
@@ -303,7 +303,7 @@ export interface SyncToPanelResponse {
|
||||
export interface PanelSyncStatusResponse {
|
||||
user_id: number;
|
||||
telegram_id: number;
|
||||
remnawave_uuid: string | null;
|
||||
remnawave_id: number | null;
|
||||
subscription_id: number | null;
|
||||
subscription_tariff_name: string | null;
|
||||
last_sync: string | null;
|
||||
@@ -456,7 +456,8 @@ export const adminUsersApi = {
|
||||
| 'traffic'
|
||||
| 'last_activity'
|
||||
| 'total_spent'
|
||||
| 'purchase_count';
|
||||
| 'purchase_count'
|
||||
| 'subscription_end_date';
|
||||
} = {},
|
||||
): Promise<UsersListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/users', { params });
|
||||
@@ -518,6 +519,19 @@ export const adminUsersApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete one of the user's subscriptions (multi-tariff: trials pile up)
|
||||
deleteSubscription: async (
|
||||
userId: number,
|
||||
subId: number,
|
||||
force = false,
|
||||
): Promise<{ status: string }> => {
|
||||
const response = await apiClient.delete(
|
||||
`/cabinet/admin/users/${userId}/subscriptions/${subId}`,
|
||||
{ params: force ? { force: true } : undefined },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update status
|
||||
updateStatus: async (
|
||||
userId: number,
|
||||
|
||||
@@ -18,12 +18,14 @@ export const authApi = {
|
||||
initData: string,
|
||||
campaignSlug?: string | null,
|
||||
referralCode?: string | null,
|
||||
acceptedLegalDocuments?: string[],
|
||||
): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', {
|
||||
init_data: initData,
|
||||
campaign_slug: campaignSlug || undefined,
|
||||
referral_code: referralCode || undefined,
|
||||
yandex_cid: getYandexCid() || undefined,
|
||||
accepted_legal_documents: acceptedLegalDocuments,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
@@ -40,12 +42,14 @@ export const authApi = {
|
||||
},
|
||||
campaignSlug?: string | null,
|
||||
referralCode?: string | null,
|
||||
acceptedLegalDocuments?: string[],
|
||||
): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', {
|
||||
...data,
|
||||
campaign_slug: campaignSlug || undefined,
|
||||
referral_code: referralCode || undefined,
|
||||
yandex_cid: getYandexCid() || undefined,
|
||||
accepted_legal_documents: acceptedLegalDocuments,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
@@ -54,12 +58,14 @@ export const authApi = {
|
||||
idToken: string,
|
||||
campaignSlug?: string | null,
|
||||
referralCode?: string | null,
|
||||
acceptedLegalDocuments?: string[],
|
||||
): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/oidc', {
|
||||
id_token: idToken,
|
||||
campaign_slug: campaignSlug || undefined,
|
||||
referral_code: referralCode || undefined,
|
||||
yandex_cid: getYandexCid() || undefined,
|
||||
accepted_legal_documents: acceptedLegalDocuments,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
@@ -115,6 +121,7 @@ export const authApi = {
|
||||
language?: string;
|
||||
referral_code?: string;
|
||||
campaign_slug?: string;
|
||||
accepted_legal_documents?: string[];
|
||||
}): Promise<RegisterResponse> => {
|
||||
const response = await apiClient.post<RegisterResponse>(
|
||||
'/cabinet/auth/email/register/standalone',
|
||||
|
||||
@@ -149,6 +149,11 @@ export const initLogoPreload = () => {
|
||||
}
|
||||
};
|
||||
|
||||
export interface BotStartVideoInfo {
|
||||
has_video: boolean;
|
||||
file_id?: string | null;
|
||||
}
|
||||
|
||||
export const brandingApi = {
|
||||
// Get current branding (public, no auth required)
|
||||
getBranding: async (): Promise<BrandingInfo> => {
|
||||
@@ -176,6 +181,29 @@ export const brandingApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// ── Видео стартового меню бота ────────────────────────────────────────
|
||||
// Хранится как Telegram file_id: файл на нашей стороне не сохраняется.
|
||||
|
||||
getBotStartVideo: async (): Promise<BotStartVideoInfo> => {
|
||||
const response = await apiClient.get<BotStartVideoInfo>('/cabinet/branding/bot-start-video');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
uploadBotStartVideo: async (file: File): Promise<BotStartVideoInfo> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await apiClient.post<BotStartVideoInfo>(
|
||||
'/cabinet/branding/bot-start-video',
|
||||
formData,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteBotStartVideo: async (): Promise<BotStartVideoInfo> => {
|
||||
const response = await apiClient.delete<BotStartVideoInfo>('/cabinet/branding/bot-start-video');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete custom logo (admin only)
|
||||
deleteLogo: async (): Promise<BrandingInfo> => {
|
||||
const response = await apiClient.delete<BrandingInfo>('/cabinet/branding/logo');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { retrieveRawInitData } from '@telegram-apps/sdk-react';
|
||||
import { getTelegramInitData as readTelegramInitData } from '../utils/telegramInitData';
|
||||
import {
|
||||
tokenStorage,
|
||||
isTokenExpired,
|
||||
@@ -41,13 +41,11 @@ function ensureCsrfToken(): string {
|
||||
const getTelegramInitData = (): string | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const raw = retrieveRawInitData();
|
||||
if (raw) {
|
||||
tokenStorage.setTelegramInitData(raw);
|
||||
return raw;
|
||||
}
|
||||
} catch {}
|
||||
const raw = readTelegramInitData();
|
||||
if (raw) {
|
||||
tokenStorage.setTelegramInitData(raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
return tokenStorage.getTelegramInitData();
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface CouponBatch {
|
||||
period_days: number;
|
||||
coupons_total: number;
|
||||
wholesale_price_kopeks: number;
|
||||
/** Сколько купонов партии может активировать один пользователь; 0 — без ограничения */
|
||||
max_per_user: number;
|
||||
valid_until: string | null;
|
||||
is_revoked: boolean;
|
||||
created_at: string;
|
||||
@@ -31,6 +33,7 @@ export interface CouponBatchCreateRequest {
|
||||
period_days: number;
|
||||
coupons_count: number;
|
||||
wholesale_price_kopeks?: number;
|
||||
max_per_user?: number;
|
||||
valid_days?: number;
|
||||
}
|
||||
|
||||
@@ -99,6 +102,12 @@ export const couponsApi = {
|
||||
},
|
||||
|
||||
// User: redeem a coupon for the current cabinet user
|
||||
/** Полное удаление партии (в отличие от revoke, который гасит ссылки) */
|
||||
deleteBatch: async (id: number): Promise<{ batch_id: number; deleted_coupons: number }> => {
|
||||
const response = await apiClient.delete(`/cabinet/admin/coupons/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
redeemCoupon: async (token: string): Promise<CouponRedeemResponse> => {
|
||||
const response = await apiClient.post('/cabinet/coupon/redeem', { token });
|
||||
return response.data;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './client';
|
||||
import type { SupportConfig } from '../types';
|
||||
import type { LegalConsentConfig, SupportConfig } from '../types';
|
||||
|
||||
export interface FaqPage {
|
||||
id: number;
|
||||
@@ -125,4 +125,12 @@ export const infoApi = {
|
||||
const response = await apiClient.get<InfoVisibility>('/cabinet/info/visibility');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Публичный: экран логина запрашивает это ДО авторизации.
|
||||
getLegalConsentConfig: async (language?: string): Promise<LegalConsentConfig> => {
|
||||
const response = await apiClient.get<LegalConsentConfig>('/cabinet/info/legal-consent', {
|
||||
params: language ? { language } : undefined,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -114,6 +114,10 @@ export interface PurchaseRequest {
|
||||
yandex_cid?: string;
|
||||
referrer?: string;
|
||||
subid?: string;
|
||||
// Слаг рекламной кампании: без него покупка гостем не попадает в статистику
|
||||
// кампании и не даёт её бонус — auth-флоу, который привязывает кампанию
|
||||
// обычно, на этом пути не срабатывает.
|
||||
campaign_slug?: string;
|
||||
}
|
||||
|
||||
export interface PurchaseResponse {
|
||||
|
||||
@@ -67,8 +67,21 @@ export interface PromoOfferBroadcastResponse {
|
||||
created_offers: number;
|
||||
user_ids: number[];
|
||||
target: string | null;
|
||||
/** Счётчики email-уведомлений; доставка в Telegram отслеживается через broadcast_id */
|
||||
notifications_sent: number;
|
||||
notifications_failed: number;
|
||||
/** Запись рассылки с прогрессом доставки в Telegram (null — слать было некому) */
|
||||
broadcast_id: number | null;
|
||||
telegram_recipients: number;
|
||||
}
|
||||
|
||||
export interface PromoOfferSegment {
|
||||
key: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PromoOfferSegmentListResponse {
|
||||
segments: PromoOfferSegment[];
|
||||
}
|
||||
|
||||
export interface PromoOfferTemplate {
|
||||
@@ -206,6 +219,12 @@ export const promoOffersApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get recipient counts per target segment
|
||||
getSegments: async (): Promise<PromoOfferSegmentListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/promo-offers/segments');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get promo offer logs
|
||||
getLogs: async (params?: {
|
||||
limit?: number;
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface PromoCode {
|
||||
balance_bonus_kopeks: number;
|
||||
balance_bonus_rubles: number;
|
||||
subscription_days: number;
|
||||
/** Гигабайты к подписке — третья составляющая набора бонусов. */
|
||||
traffic_gb: number;
|
||||
max_uses: number;
|
||||
current_uses: number;
|
||||
uses_left: number;
|
||||
@@ -60,6 +62,7 @@ export interface PromoCodeCreateRequest {
|
||||
type: PromoCodeType;
|
||||
balance_bonus_kopeks?: number;
|
||||
subscription_days?: number;
|
||||
traffic_gb?: number;
|
||||
max_uses?: number;
|
||||
valid_from?: string;
|
||||
valid_until?: string | null;
|
||||
@@ -74,6 +77,7 @@ export interface PromoCodeUpdateRequest {
|
||||
type?: PromoCodeType;
|
||||
balance_bonus_kopeks?: number;
|
||||
subscription_days?: number;
|
||||
traffic_gb?: number;
|
||||
max_uses?: number;
|
||||
valid_from?: string;
|
||||
valid_until?: string | null;
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
PurchasePreview,
|
||||
AppConfig,
|
||||
SbpRecurringInfo,
|
||||
LavaRecurringInfo,
|
||||
} from '../types';
|
||||
|
||||
/** Helper: build query params with optional subscription_id */
|
||||
@@ -394,6 +395,49 @@ export const subscriptionApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// ── Recurring (Lava) ──────────────────────────────────────────────────
|
||||
|
||||
getLavaRecurring: async (subscriptionId?: number): Promise<LavaRecurringInfo> => {
|
||||
const response = await apiClient.get<LavaRecurringInfo>(
|
||||
'/cabinet/subscription/lava-recurrent',
|
||||
withSubId(subscriptionId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
enableLavaRecurring: async (
|
||||
subscriptionId?: number,
|
||||
): Promise<{ status: string; redirect_url: string | null }> => {
|
||||
const response = await apiClient.post(
|
||||
'/cabinet/subscription/lava-recurrent/enable',
|
||||
...bodyWithSubId({}, subscriptionId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
cancelLavaRecurring: async (subscriptionId?: number): Promise<{ status: string }> => {
|
||||
const response = await apiClient.post(
|
||||
'/cabinet/subscription/lava-recurrent/cancel',
|
||||
...bodyWithSubId({}, subscriptionId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Оформление подписки на тариф привязкой Lava: первое списание оплачивается
|
||||
* по возвращённой ссылке и активирует подписку.
|
||||
*/
|
||||
purchaseWithLavaRecurring: async (
|
||||
tariffId: number,
|
||||
): Promise<{ status: string; redirect_url: string | null; subscription_id: number }> => {
|
||||
const response = await apiClient.post(
|
||||
'/cabinet/subscription/lava-recurrent/purchase',
|
||||
{},
|
||||
{ params: { tariff_id: tariffId } },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// ── Trial ───────────────────────────────────────────────────────────
|
||||
|
||||
getTrialInfo: async (): Promise<TrialInfo> => {
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface TariffListItem {
|
||||
show_in_gift: boolean;
|
||||
is_daily: boolean;
|
||||
daily_price_kopeks: number;
|
||||
/** UUID продукта Lava для рекуррентных подписок (цена/период заданы в кабинете Lava) */
|
||||
lava_product_id?: string | null;
|
||||
traffic_limit_gb: number;
|
||||
device_limit: number;
|
||||
tier_level: number;
|
||||
@@ -85,6 +87,8 @@ export interface TariffDetail {
|
||||
// Дневной тариф
|
||||
is_daily: boolean;
|
||||
daily_price_kopeks: number;
|
||||
/** UUID продукта Lava для рекуррентных подписок (цена/период заданы в кабинете Lava) */
|
||||
lava_product_id?: string | null;
|
||||
// Режим сброса трафика
|
||||
traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'MONTH_ROLLING', 'NO_RESET', null = глобальная настройка
|
||||
// Внешний сквад Remnawave
|
||||
@@ -124,6 +128,8 @@ export interface TariffCreateRequest {
|
||||
// Дневной тариф
|
||||
is_daily?: boolean;
|
||||
daily_price_kopeks?: number;
|
||||
// Автопродление Lava: продукт из кабинета Lava
|
||||
lava_product_id?: string | null;
|
||||
// Режим сброса трафика
|
||||
traffic_reset_mode?: string | null;
|
||||
// Внешний сквад Remnawave
|
||||
@@ -168,6 +174,8 @@ export interface TariffUpdateRequest {
|
||||
// Дневной тариф
|
||||
is_daily?: boolean;
|
||||
daily_price_kopeks?: number;
|
||||
// Автопродление Lava: продукт из кабинета Lava
|
||||
lava_product_id?: string | null;
|
||||
// Режим сброса трафика
|
||||
traffic_reset_mode?: string | null;
|
||||
// Внешний сквад Remnawave
|
||||
|
||||
82
src/components/LegalConsent.tsx
Normal file
82
src/components/LegalConsent.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Галочки «ознакомлен» для НОВОГО пользователя. Набор документов приходит с бэка
|
||||
// (GET /cabinet/info/legal-consent) — там же решается, включён ли гейт вообще и
|
||||
// какие документы реально заполнены. Здесь только отрисовка и ссылки: адреса
|
||||
// публичных страниц знает фронт, бэк отдаёт лишь ключи.
|
||||
|
||||
export const LEGAL_DOCUMENT_LINKS: Record<string, string> = {
|
||||
public_offer: '/offer',
|
||||
privacy_policy: '/privacy',
|
||||
};
|
||||
|
||||
const LEGAL_DOCUMENT_LABELS: Record<string, { key: string; fallback: string }> = {
|
||||
public_offer: { key: 'footer.offer', fallback: 'Публичная оферта' },
|
||||
privacy_policy: { key: 'footer.privacy', fallback: 'Политика конфиденциальности' },
|
||||
};
|
||||
|
||||
interface LegalConsentProps {
|
||||
documents: string[];
|
||||
accepted: Record<string, boolean>;
|
||||
onChange: (document: string, value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function LegalConsent({
|
||||
documents,
|
||||
accepted,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
}: LegalConsentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (documents.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={`space-y-2.5 ${className}`}>
|
||||
{documents.map((document) => {
|
||||
// Неизвестный ключ всё равно показываем: он обязателен на бэке, и молча
|
||||
// спрятанный чекбокс превратился бы в «кнопка не работает без объяснений».
|
||||
const label = LEGAL_DOCUMENT_LABELS[document];
|
||||
const href = LEGAL_DOCUMENT_LINKS[document];
|
||||
const title = label ? t(label.key, label.fallback) : document;
|
||||
const inputId = `legal-consent-${document}`;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={document}
|
||||
htmlFor={inputId}
|
||||
className="flex cursor-pointer items-start gap-2.5 text-xs leading-relaxed text-dark-400"
|
||||
>
|
||||
<input
|
||||
id={inputId}
|
||||
type="checkbox"
|
||||
checked={Boolean(accepted[document])}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(document, e.target.checked)}
|
||||
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded border-dark-600 bg-dark-800 text-accent-500 focus:ring-accent-500"
|
||||
/>
|
||||
<span>
|
||||
{t('auth.legalConsentPrefix', 'Я ознакомлен(а) с документом')}{' '}
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent-400 underline underline-offset-2 transition-colors hover:text-accent-300"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-dark-200">{title}</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,10 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
||||
const [oidcError, setOidcError] = useState('');
|
||||
const [scriptLoaded, setScriptLoaded] = useState(false);
|
||||
const [scriptFailed, setScriptFailed] = useState(false);
|
||||
// Lets the user opt into deep-link auth manually, without waiting for the
|
||||
// Telegram widget script to fail. See #<issue-number>.
|
||||
const [manualDeepLink, setManualDeepLink] = useState(false);
|
||||
const showDeepLinkUI = scriptFailed || manualDeepLink;
|
||||
const loginWithTelegramOIDC = useAuthStore((s) => s.loginWithTelegramOIDC);
|
||||
|
||||
// Deep link auth state
|
||||
@@ -168,7 +172,12 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
||||
const loginWithTelegramWidget = useAuthStore((s) => s.loginWithTelegramWidget);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOIDC || !containerRef.current || !botUsername || !widgetConfig) return;
|
||||
// showDeepLinkUI обязан быть в зависимостях: пока он true, контейнер
|
||||
// виджета размонтирован, а при возврате «Назад к виджету» сам по себе
|
||||
// эффект не перезапустится — на legacy-пути scriptLoaded не меняется
|
||||
// никогда, поэтому ни одна из остальных зависимостей не дрогнет, и
|
||||
// пользователь получил бы пустое место вместо виджета.
|
||||
if (showDeepLinkUI || isOIDC || !containerRef.current || !botUsername || !widgetConfig) return;
|
||||
|
||||
const container = containerRef.current;
|
||||
while (container.firstChild) {
|
||||
@@ -229,7 +238,15 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
};
|
||||
}, [isOIDC, botUsername, widgetConfig, loginWithTelegramWidget, navigate, handleScriptFailed]);
|
||||
}, [
|
||||
showDeepLinkUI,
|
||||
isOIDC,
|
||||
botUsername,
|
||||
widgetConfig,
|
||||
loginWithTelegramWidget,
|
||||
navigate,
|
||||
handleScriptFailed,
|
||||
]);
|
||||
|
||||
// Deep link auth: request token and start polling with recursive setTimeout
|
||||
const startDeepLinkAuth = useCallback(async () => {
|
||||
@@ -336,9 +353,10 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
||||
}
|
||||
}, [botUsername, loginWithDeepLink, navigate, t]);
|
||||
|
||||
// Auto-start deep link auth when script fails (with cancellation for Strict Mode)
|
||||
// Auto-start deep link auth when script fails OR the user opts in manually
|
||||
// (with cancellation for Strict Mode)
|
||||
useEffect(() => {
|
||||
if (scriptFailed && !deepLinkToken && !deepLinkPolling) {
|
||||
if (showDeepLinkUI && !deepLinkToken && !deepLinkPolling) {
|
||||
let cancelled = false;
|
||||
const start = async () => {
|
||||
if (!cancelled) await startDeepLinkAuth();
|
||||
@@ -348,7 +366,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
}, [scriptFailed, deepLinkToken, deepLinkPolling, startDeepLinkAuth]);
|
||||
}, [showDeepLinkUI, deepLinkToken, deepLinkPolling, startDeepLinkAuth]);
|
||||
|
||||
// Resume polling immediately when user returns to the page (e.g. after confirming in Telegram)
|
||||
// Browsers throttle setTimeout in background tabs, so polling may have stalled.
|
||||
@@ -431,8 +449,9 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
||||
);
|
||||
}
|
||||
|
||||
// Deep link fallback UI
|
||||
if (scriptFailed) {
|
||||
// Deep link UI — shown either as an automatic fallback (widget script
|
||||
// failed to load) or because the user explicitly chose this method.
|
||||
if (showDeepLinkUI) {
|
||||
const resolvedBotUsername = deepLinkBotUsername || botUsername;
|
||||
const deepLinkUrl = deepLinkToken
|
||||
? `https://t.me/${resolvedBotUsername}?start=webauth_${deepLinkToken}`
|
||||
@@ -443,7 +462,7 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
||||
<div className="flex flex-col items-center space-y-5">
|
||||
{/* Info message */}
|
||||
<p className="max-w-xs text-center text-xs text-dark-400">
|
||||
{t('auth.telegramWidgetBlocked')}
|
||||
{t(scriptFailed ? 'auth.telegramWidgetBlocked' : 'auth.deepLinkIntro')}
|
||||
</p>
|
||||
|
||||
{deepLinkToken && deepLinkUrl ? (
|
||||
@@ -517,6 +536,27 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Only offer a way back if the widget actually works — if the
|
||||
script failed there is nothing to go back to. */}
|
||||
{!scriptFailed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (pollTimeoutRef.current) clearTimeout(pollTimeoutRef.current);
|
||||
if (expireTimeoutRef.current) clearTimeout(expireTimeoutRef.current);
|
||||
pollTimeoutRef.current = null;
|
||||
expireTimeoutRef.current = null;
|
||||
setDeepLinkToken(null);
|
||||
setDeepLinkPolling(false);
|
||||
setDeepLinkError('');
|
||||
setManualDeepLink(false);
|
||||
}}
|
||||
className="text-xs text-dark-400 underline decoration-dotted transition-colors hover:text-dark-300"
|
||||
>
|
||||
{t('auth.backToWidget')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -551,24 +591,41 @@ export default function TelegramLoginButton({ referralCode }: TelegramLoginButto
|
||||
<div ref={containerRef} className="flex justify-center" />
|
||||
)}
|
||||
|
||||
<div className="text-center">
|
||||
<p className="mb-2 text-xs text-dark-400">{t('auth.orOpenInApp')}</p>
|
||||
{/* Referral deep link — only relevant for not-yet-registered users who
|
||||
arrived via a referral link; the bot itself handles registering
|
||||
them with the code attached. Hidden otherwise to avoid a third,
|
||||
visually-identical "Telegram" entry point next to the two auth
|
||||
methods below. */}
|
||||
{referralCode && (
|
||||
<a
|
||||
href={
|
||||
referralCode
|
||||
? `https://t.me/${botUsername}?start=${encodeURIComponent(referralCode)}`
|
||||
: `https://t.me/${botUsername}`
|
||||
}
|
||||
href={`https://t.me/${botUsername}?start=${encodeURIComponent(referralCode)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-telegram-blue inline-flex items-center text-sm hover:underline"
|
||||
className="text-telegram-blue inline-flex items-center text-xs hover:underline"
|
||||
>
|
||||
<svg className="mr-1 h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
@{botUsername}
|
||||
{t('auth.orOpenInApp')} @{botUsername}
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div className="flex w-full max-w-xs items-center gap-3">
|
||||
<div className="h-px flex-1 bg-dark-700" />
|
||||
<span className="text-[11px] text-dark-500">{t('common.or')}</span>
|
||||
<div className="h-px flex-1 bg-dark-700" />
|
||||
</div>
|
||||
|
||||
{/* Manual opt-in: same deep-link flow used as the anti-block fallback,
|
||||
offered here as an explicit equal alternative to the widget for
|
||||
users who'd rather confirm in the bot than type a phone number. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManualDeepLink(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-dark-700 bg-dark-800/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:border-dark-600 hover:bg-dark-800"
|
||||
>
|
||||
<svg className="h-5 w-5 text-telegram-blue" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
{t('auth.loginWithBot')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const startVideoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
@@ -25,6 +26,12 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
queryFn: brandingApi.getBranding,
|
||||
});
|
||||
|
||||
// Видео стартового меню бота: хранится как Telegram file_id
|
||||
const { data: startVideo } = useQuery({
|
||||
queryKey: ['bot-start-video'],
|
||||
queryFn: brandingApi.getBotStartVideo,
|
||||
});
|
||||
|
||||
const { data: fullscreenSettings } = useQuery({
|
||||
queryKey: ['fullscreen-enabled'],
|
||||
queryFn: brandingApi.getFullscreenEnabled,
|
||||
@@ -100,6 +107,29 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
},
|
||||
});
|
||||
|
||||
const uploadStartVideoMutation = useMutation({
|
||||
mutationFn: brandingApi.uploadBotStartVideo,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['bot-start-video'] });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteStartVideoMutation = useMutation({
|
||||
mutationFn: brandingApi.deleteBotStartVideo,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['bot-start-video'] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleStartVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
uploadStartVideoMutation.mutate(file);
|
||||
}
|
||||
// Сбрасываем input, чтобы повторный выбор того же файла снова сработал
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
@@ -211,6 +241,57 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Видео стартового меню бота */}
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<h3 className="mb-1 text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.botStartVideo')}
|
||||
</h3>
|
||||
<p className="mb-4 text-sm text-dark-400">{t('admin.settings.botStartVideoDesc')}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<input
|
||||
ref={startVideoInputRef}
|
||||
type="file"
|
||||
accept="video/*"
|
||||
onChange={handleStartVideoUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => startVideoInputRef.current?.click()}
|
||||
disabled={uploadStartVideoMutation.isPending}
|
||||
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
{uploadStartVideoMutation.isPending
|
||||
? t('common.loading')
|
||||
: startVideo?.has_video
|
||||
? t('admin.settings.botStartVideoReplace')
|
||||
: t('admin.settings.botStartVideoUpload')}
|
||||
</button>
|
||||
|
||||
{startVideo?.has_video && (
|
||||
<button
|
||||
onClick={() => deleteStartVideoMutation.mutate()}
|
||||
disabled={deleteStartVideoMutation.isPending}
|
||||
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-400 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:opacity-50"
|
||||
>
|
||||
{t('admin.settings.botStartVideoRemove')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-dark-400">
|
||||
{startVideo?.has_video
|
||||
? t('admin.settings.botStartVideoActive')
|
||||
: t('admin.settings.botStartVideoNone')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{uploadStartVideoMutation.isError && (
|
||||
<div className="mt-3 text-sm text-error-400">
|
||||
{t('admin.settings.botStartVideoError')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Animated Background Editor */}
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<BackgroundEditor />
|
||||
|
||||
@@ -3,10 +3,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
buttonStylesApi,
|
||||
ButtonStylesConfig,
|
||||
type ButtonStylesConfig,
|
||||
DEFAULT_BUTTON_STYLES,
|
||||
BUTTON_SECTIONS,
|
||||
ButtonSection,
|
||||
type ButtonSection,
|
||||
BOT_LOCALES,
|
||||
} from '../../api/buttonStyles';
|
||||
import { ChevronDownIcon } from '@/components/icons';
|
||||
@@ -18,7 +18,10 @@ type StyleValue = 'primary' | 'success' | 'danger' | 'default';
|
||||
|
||||
const STYLE_OPTIONS: { value: StyleValue; colorClass: string }[] = [
|
||||
{ value: 'default', colorClass: 'bg-dark-500' },
|
||||
{ value: 'primary', colorClass: 'bg-accent-500' },
|
||||
// 'primary' — синяя кнопка Telegram Bot API: свотч фиксированный телеграм-синий,
|
||||
// а не bg-accent-500 — акцент темы кабинета перекрашивается (например, в оранжевый)
|
||||
// и не имеет отношения к цвету реальной кнопки в боте.
|
||||
{ value: 'primary', colorClass: 'bg-[#54a9eb]' },
|
||||
{ value: 'success', colorClass: 'bg-success-500' },
|
||||
{ value: 'danger', colorClass: 'bg-error-500' },
|
||||
];
|
||||
@@ -228,7 +231,7 @@ export function ButtonsTab() {
|
||||
? 'bg-success-500 text-white'
|
||||
: cfg.style === 'danger'
|
||||
? 'bg-error-500 text-white'
|
||||
: 'bg-accent-500 text-on-accent'
|
||||
: 'bg-[#54a9eb] text-white'
|
||||
}`}
|
||||
>
|
||||
{t(`admin.buttons.styles.${cfg.style}`)}
|
||||
|
||||
44
src/components/admin/constants.test.ts
Normal file
44
src/components/admin/constants.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { OTHER_CATEGORIES, SETTINGS_TREE, getMappedCategoryKeys } from './constants';
|
||||
|
||||
// Категории настроек на бэкенде выводятся из имени ключа, поэтому каждая новая
|
||||
// настройка может создать категорию, которой в дереве нет. Раньше такие настройки
|
||||
// просто не отображались: так исчезла вся категория CABINET (13 настроек, включая
|
||||
// CABINET_ENABLED и CABINET_URL) и правовые документы из INFO_PAGES.
|
||||
|
||||
describe('SETTINGS_TREE', () => {
|
||||
it('содержит пункт-сборник для категорий без своего места', () => {
|
||||
const children = SETTINGS_TREE.groups.flatMap((group) => group.children);
|
||||
const catchAll = children.filter((child) => child.categories.includes(OTHER_CATEGORIES));
|
||||
|
||||
expect(catchAll).toHaveLength(1);
|
||||
expect(catchAll[0].id).toBe('sys_other');
|
||||
});
|
||||
|
||||
it('не считает маркер сборника обычной категорией', () => {
|
||||
expect(getMappedCategoryKeys().has(OTHER_CATEGORIES)).toBe(false);
|
||||
});
|
||||
|
||||
it('раскладывает по дереву категории, которые раньше терялись', () => {
|
||||
const mapped = getMappedCategoryKeys();
|
||||
|
||||
for (const category of ['CABINET', 'INFO_PAGES', 'MENU']) {
|
||||
expect(mapped.has(category)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('незнакомая категория попадает в сборник, а не пропадает', () => {
|
||||
const mapped = getMappedCategoryKeys();
|
||||
// Так выглядит любая категория, появившаяся на бэкенде после этого релиза.
|
||||
expect(mapped.has('SOME_FUTURE_CATEGORY')).toBe(false);
|
||||
});
|
||||
|
||||
it('не содержит категорию дважды', () => {
|
||||
const all = SETTINGS_TREE.groups
|
||||
.flatMap((group) => group.children)
|
||||
.flatMap((child) => child.categories)
|
||||
.filter((category) => category !== OTHER_CATEGORIES);
|
||||
|
||||
expect(new Set(all).size).toBe(all.length);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme';
|
||||
import { type ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme';
|
||||
|
||||
// Tree sidebar types
|
||||
export interface TreeSubItem {
|
||||
@@ -23,6 +23,9 @@ export interface SettingsTreeConfig {
|
||||
groups: TreeGroup[];
|
||||
}
|
||||
|
||||
// Маркер пункта-сборника: подставляется вместо списка категорий.
|
||||
export const OTHER_CATEGORIES = '*';
|
||||
|
||||
// Hierarchical settings tree — all 61 backend category keys mapped into 7 groups
|
||||
export const SETTINGS_TREE: SettingsTreeConfig = {
|
||||
specialItems: [
|
||||
@@ -91,6 +94,8 @@ export const SETTINGS_TREE: SettingsTreeConfig = {
|
||||
{ id: 'iface_widget', categories: ['TELEGRAM_WIDGET'] },
|
||||
{ id: 'iface_oidc', categories: ['TELEGRAM_OIDC'] },
|
||||
{ id: 'iface_skip', categories: ['SKIP'] },
|
||||
{ id: 'iface_info', categories: ['INFO_PAGES'] },
|
||||
{ id: 'iface_menu', categories: ['MENU'] },
|
||||
{ id: 'iface_additional', categories: ['ADDITIONAL'] },
|
||||
],
|
||||
},
|
||||
@@ -131,6 +136,7 @@ export const SETTINGS_TREE: SettingsTreeConfig = {
|
||||
{ id: 'sys_core', categories: ['CORE', 'DEBUG'] },
|
||||
{ id: 'sys_remnawave', categories: ['REMNAWAVE'] },
|
||||
{ id: 'sys_webapi', categories: ['WEB_API', 'EXTERNAL_ADMIN'] },
|
||||
{ id: 'sys_cabinet', categories: ['CABINET'] },
|
||||
{ id: 'sys_webhook', categories: ['WEBHOOK'] },
|
||||
{ id: 'sys_server', categories: ['SERVER_STATUS'] },
|
||||
{ id: 'sys_monitoring', categories: ['MONITORING'] },
|
||||
@@ -138,11 +144,29 @@ export const SETTINGS_TREE: SettingsTreeConfig = {
|
||||
{ id: 'sys_backup', categories: ['BACKUP'] },
|
||||
{ id: 'sys_version', categories: ['VERSION'] },
|
||||
{ id: 'sys_logging', categories: ['LOG'] },
|
||||
// Категории на бэкенде выводятся из имени ключа, поэтому каждая новая
|
||||
// настройка может создать категорию, которой в дереве нет — и тогда она
|
||||
// просто исчезала из админки (так пропала вся категория CABINET).
|
||||
// Этот пункт собирает всё неразложенное, как «Прочее» в админке бота.
|
||||
{ id: 'sys_other', categories: [OTHER_CATEGORIES] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Категории, у которых есть своё место в дереве (маркер сборника не считается).
|
||||
export function getMappedCategoryKeys(): Set<string> {
|
||||
const mapped = new Set<string>();
|
||||
for (const group of SETTINGS_TREE.groups) {
|
||||
for (const child of group.children) {
|
||||
for (const category of child.categories) {
|
||||
if (category !== OTHER_CATEGORIES) mapped.add(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
// Helper: find which group and sub-item a backend category key belongs to
|
||||
export function findTreeLocation(
|
||||
categoryKey: string,
|
||||
|
||||
167
src/components/admin/remnawave/GeoCheckImageViewer.tsx
Normal file
167
src/components/admin/remnawave/GeoCheckImageViewer.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TransformComponent, TransformWrapper } from 'react-zoom-pan-pinch';
|
||||
import { MinusIcon, PlusIcon, ResetIcon } from '@/components/icons';
|
||||
|
||||
/**
|
||||
* Ширина в CSS-пикселях, при которой моноширинный отчёт ещё читается.
|
||||
* Отчёт всегда заметно шире телефона, поэтому «вписать по ширине» на узком
|
||||
* экране превращает его в серую рябь. Стартуем с масштаба, дающего примерно
|
||||
* такую ширину, а вписать целиком всегда можно кнопкой сброса и жестом.
|
||||
*/
|
||||
const READABLE_WIDTH = 760;
|
||||
|
||||
/** Масштаб 1 — отчёт вписан в ширину области; ниже опускаться незачем. */
|
||||
/** Ширины строк-заглушек, повторяющие ритм отчёта: заголовок, строки, пробел. */
|
||||
const SKELETON_ROWS = [42, 88, 80, 84, 30, 70, 76, 82, 64, 28, 86, 74, 80, 68];
|
||||
|
||||
const SCALE_MIN = 1;
|
||||
const SCALE_MAX = 8;
|
||||
|
||||
interface GeoCheckImageViewerProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
/** Смена режима меняет доступную ширину — просмотрщик пересобирается. */
|
||||
fullscreen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Просмотр широкого SVG-отчёта: зум, перетаскивание, пинч, двойной тап.
|
||||
*
|
||||
* Зум свой, а не браузерный: в `index.html` приложения стоит
|
||||
* `user-scalable=no`, поэтому нативного пинча нет ни в мобильном вебе, ни в
|
||||
* Mini App. Библиотека берёт на себя все жесты и работает одинаково от мыши,
|
||||
* пальца и трекпада — так же это сделано в самой панели Remnawave.
|
||||
*
|
||||
* Колесо намеренно не масштабирует (`wheelDisabled`): иначе обычная прокрутка
|
||||
* над отчётом превращалась бы в зум.
|
||||
*/
|
||||
export function GeoCheckImageViewer({ src, alt, fullscreen }: GeoCheckImageViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
// Отчёт весит сотни килобайт вместе со встроенным шрифтом, и между приходом
|
||||
// данных и первой отрисовкой есть заметная пауза. До неё показываем
|
||||
// скелетон, а не пустую тёмную коробку с кнопками зума.
|
||||
//
|
||||
// Готовность хранится как «какая картинка отрисована», а не флагом: тогда
|
||||
// новый отчёт автоматически считается незагруженным, без сбрасывающего
|
||||
// эффекта.
|
||||
const [loadedSrc, setLoadedSrc] = useState<string | null>(null);
|
||||
const loaded = loadedSrc === src;
|
||||
|
||||
// Подпись отчёта для ключа. Длины и хвоста base64 недостаточно: отчёты
|
||||
// одного узла кончаются одинаково и совпали бы по такой подписи, а зум
|
||||
// тогда не сбрасывается. Полная строка в ключе — сотни килобайт на каждый
|
||||
// рендер, поэтому считаем хеш один раз на отчёт.
|
||||
const srcId = useMemo(() => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < src.length; i += 1) {
|
||||
hash = (hash * 31 + src.charCodeAt(i)) | 0;
|
||||
}
|
||||
return `${src.length}:${hash}`;
|
||||
}, [src]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host) return;
|
||||
|
||||
const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
|
||||
observer.observe(host);
|
||||
setWidth(host.clientWidth);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Масштаб 1 = вписано по ширине. На широком экране этого достаточно, на
|
||||
// узком — поднимаем до читаемого и позволяем панорамировать.
|
||||
const initialScale = width > 0 ? Math.min(SCALE_MAX, Math.max(1, READABLE_WIDTH / width)) : 1;
|
||||
|
||||
const controlButton =
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg text-dark-200 transition-colors hover:bg-dark-100/10 active:scale-95';
|
||||
|
||||
return (
|
||||
<div ref={hostRef} className="relative h-full">
|
||||
{!loaded && (
|
||||
<div className="absolute inset-0 z-10 space-y-2 p-4" aria-busy="true">
|
||||
{SKELETON_ROWS.map((w) => (
|
||||
<div
|
||||
key={w}
|
||||
className="h-3 animate-pulse rounded bg-dark-700/50"
|
||||
style={{ width: `${w}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{width > 0 && (
|
||||
<TransformWrapper
|
||||
// Пересобираем, когда меняется нужный стартовый масштаб: полный экран,
|
||||
// поворот телефона, разворот окна. Ключ огрублён до десятых — пока
|
||||
// отчёт и так вписывается (масштаб 1), перетаскивание окна мышью
|
||||
// ничего не сбрасывает.
|
||||
// `src` в ключе: перезапуск проверки даёт новый отчёт, и он должен
|
||||
// открыться в исходном масштабе, а не унаследовать зум и сдвиг от
|
||||
// предыдущего.
|
||||
key={`${srcId}-${fullscreen}-${Math.round(initialScale * 10)}`}
|
||||
centerOnInit={false}
|
||||
disablePadding
|
||||
doubleClick={{ mode: 'toggle' }}
|
||||
initialScale={initialScale}
|
||||
maxScale={SCALE_MAX}
|
||||
minScale={SCALE_MIN}
|
||||
trackPadPanning={{ disabled: false }}
|
||||
wheel={{ wheelDisabled: true }}
|
||||
>
|
||||
{({ resetTransform, zoomIn, zoomOut }) => (
|
||||
<>
|
||||
<TransformComponent
|
||||
contentStyle={{ width: '100%' }}
|
||||
// touch-action: none — все жесты обрабатывает библиотека,
|
||||
// иначе браузер перехватит их под прокрутку.
|
||||
wrapperClass="!h-full !w-full cursor-grab touch-none active:cursor-grabbing"
|
||||
>
|
||||
<img
|
||||
alt={alt}
|
||||
className="block w-full select-none"
|
||||
draggable={false}
|
||||
src={src}
|
||||
onLoad={() => setLoadedSrc(src)}
|
||||
onError={() => setLoadedSrc(src)}
|
||||
/>
|
||||
</TransformComponent>
|
||||
|
||||
{loaded && (
|
||||
<div className="absolute bottom-3 right-3 z-10 flex items-center gap-0.5 rounded-xl border border-dark-100/10 bg-dark-950/70 p-1 shadow-lg backdrop-blur-md">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomOut()}
|
||||
className={controlButton}
|
||||
aria-label={t('admin.remnawave.geoCheck.zoomOut', 'Zoom out')}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => zoomIn()}
|
||||
className={controlButton}
|
||||
aria-label={t('admin.remnawave.geoCheck.zoomIn', 'Zoom in')}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<span aria-hidden className="mx-0.5 h-5 w-px bg-dark-100/15" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetTransform()}
|
||||
className={controlButton}
|
||||
aria-label={t('admin.remnawave.geoCheck.zoomReset', 'Reset zoom')}
|
||||
>
|
||||
<ResetIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
src/components/admin/remnawave/GeoCheckModal.tsx
Normal file
234
src/components/admin/remnawave/GeoCheckModal.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NodeInfo } from '@/api/adminRemnawave';
|
||||
import { GeoCheckIcon, PlayIcon, WarningIcon, XCloseIcon } from '@/components/icons';
|
||||
import { Spinner } from '@/components/ui/Spinner';
|
||||
import { useFocusTrap } from '@/hooks/useFocusTrap';
|
||||
import { useIsTelegram } from '@/platform/hooks/usePlatform';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { GeoCheckReport } from './GeoCheckReport';
|
||||
import { buildGeoCheckRequest, isRouteReady, type GeoCheckRouteMode } from './geoCheckRoute';
|
||||
import { GeoCheckSetup } from './GeoCheckSetup';
|
||||
import { useGeoCheckJob } from './useGeoCheckJob';
|
||||
|
||||
interface GeoCheckModalProps {
|
||||
node: NodeInfo;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* GeoCheck ноды: выбор маршрута → ожидание → отчёт.
|
||||
*
|
||||
* Полноэкранный просмотр — режим самой модалки, а не отдельный оверлей:
|
||||
* оверлей поверх портала выпал бы из ловушки фокуса, и его кнопка закрытия
|
||||
* стала бы недоступна с клавиатуры.
|
||||
*/
|
||||
export function GeoCheckModal({ node, onClose }: GeoCheckModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [mode, setMode] = useState<GeoCheckRouteMode>('default');
|
||||
const [value, setValue] = useState('');
|
||||
const [fullscreenRequested, setFullscreenRequested] = useState(false);
|
||||
|
||||
// В Mini App окно Telegram и так занимает экран целиком: отдельный
|
||||
// полноэкранный режим там только снимал отступы и залезал под хром.
|
||||
// Тот же признак закрывает и скачивание: в webview Telegram оно уводит
|
||||
// из приложения вместо сохранения файла.
|
||||
const isTelegram = useIsTelegram();
|
||||
const canFullscreen = !isTelegram;
|
||||
const canDownload = !isTelegram;
|
||||
const fullscreen = canFullscreen && fullscreenRequested;
|
||||
|
||||
const job = useGeoCheckJob(node.uuid);
|
||||
const isRunning = job.phase === 'running';
|
||||
|
||||
// Пока идёт проверка, закрывать по Escape нельзя: задача уже поставлена
|
||||
// в панель, и молча потерять её результат — хуже, чем подождать.
|
||||
const dialogRef = useFocusTrap<HTMLDivElement>(true, {
|
||||
onEscape: isRunning ? undefined : fullscreen ? () => setFullscreenRequested(false) : onClose,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (job.phase !== 'done') setFullscreenRequested(false);
|
||||
}, [job.phase]);
|
||||
|
||||
const canStart = isRouteReady(mode, value);
|
||||
|
||||
const handleStart = () => {
|
||||
if (!canStart) return;
|
||||
job.start(buildGeoCheckRequest(mode, value));
|
||||
};
|
||||
|
||||
const handleBackdropClick = () => {
|
||||
if (!isRunning) onClose();
|
||||
};
|
||||
|
||||
const errorText =
|
||||
job.error?.kind === 'timeout'
|
||||
? t(
|
||||
'admin.remnawave.geoCheck.error.timeout',
|
||||
'The node did not answer in time. Try running the check again.',
|
||||
)
|
||||
: (job.error?.message ??
|
||||
t('admin.remnawave.geoCheck.error.generic', 'The check could not be completed.'));
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center"
|
||||
// Safe-зоны обязательны и в полноэкранном режиме: в Mini App сверху
|
||||
// висит шапка Telegram, снизу — home indicator, и обнулённые отступы
|
||||
// прятали под ними панель инструментов и кнопки зума.
|
||||
style={{
|
||||
paddingTop: fullscreen ? 'env(safe-area-inset-top)' : 'max(1rem, env(safe-area-inset-top))',
|
||||
paddingBottom: fullscreen
|
||||
? 'env(safe-area-inset-bottom)'
|
||||
: 'max(1rem, env(safe-area-inset-bottom))',
|
||||
paddingLeft: fullscreen
|
||||
? 'env(safe-area-inset-left)'
|
||||
: 'max(1rem, env(safe-area-inset-left))',
|
||||
paddingRight: fullscreen
|
||||
? 'env(safe-area-inset-right)'
|
||||
: 'max(1rem, env(safe-area-inset-right))',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-dark-950/80 backdrop-blur-sm"
|
||||
onClick={handleBackdropClick}
|
||||
/>
|
||||
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="geocheck-modal-title"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
'relative flex w-full flex-col border border-dark-700 bg-dark-900 shadow-2xl',
|
||||
// max-h-full, а не 100dvh: отступы safe-зон уже урезали контейнер,
|
||||
// и абсолютная высота вылезала бы за них.
|
||||
fullscreen
|
||||
? 'h-full max-h-full max-w-none rounded-none p-3'
|
||||
: 'max-h-full overflow-y-auto rounded-2xl p-5',
|
||||
// Отчёт заметно шире формы запуска — под него модалка расширяется.
|
||||
!fullscreen && (job.phase === 'done' ? 'max-w-4xl' : 'max-w-2xl'),
|
||||
)}
|
||||
>
|
||||
{/* В полноэкранном режиме шапка ужимается в одну строку: место по
|
||||
вертикали — ровно то, ради чего его и включают. */}
|
||||
<div
|
||||
className={cn('flex items-center justify-between gap-3', fullscreen ? 'mb-2' : 'mb-4')}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-xl bg-accent-500/15 text-accent-400',
|
||||
fullscreen ? 'h-7 w-7' : 'h-9 w-9',
|
||||
)}
|
||||
>
|
||||
<GeoCheckIcon className={fullscreen ? 'h-4 w-4' : 'h-5 w-5'} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h3
|
||||
id="geocheck-modal-title"
|
||||
className={cn(
|
||||
'font-semibold text-dark-100',
|
||||
fullscreen ? 'truncate text-sm' : 'text-lg',
|
||||
)}
|
||||
>
|
||||
{fullscreen ? node.name : t('admin.remnawave.geoCheck.title', 'GeoCheck')}
|
||||
</h3>
|
||||
{!fullscreen && <p className="truncate text-xs text-dark-400">{node.name}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{!isRunning && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close', 'Close')}
|
||||
className="-mr-1.5 flex min-h-[44px] min-w-[44px] shrink-0 items-center justify-center rounded-lg text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
|
||||
>
|
||||
<XCloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{job.phase === 'idle' && (
|
||||
<>
|
||||
<GeoCheckSetup
|
||||
node={node}
|
||||
mode={mode}
|
||||
value={value}
|
||||
onModeChange={(next) => {
|
||||
setMode(next);
|
||||
setValue('');
|
||||
}}
|
||||
onValueChange={setValue}
|
||||
/>
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<button type="button" onClick={onClose} className="btn-ghost">
|
||||
{t('common.close', 'Close')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStart}
|
||||
disabled={!canStart}
|
||||
className="btn-primary"
|
||||
>
|
||||
<PlayIcon className="h-4 w-4" />
|
||||
{t('admin.remnawave.geoCheck.start', 'Run check')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isRunning && (
|
||||
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
|
||||
<Spinner className="h-10 w-10" />
|
||||
<p className="text-sm font-medium text-dark-100">
|
||||
{t('admin.remnawave.geoCheck.running', 'Running the geo check')}
|
||||
</p>
|
||||
<p className="max-w-sm text-xs text-dark-400">
|
||||
{t(
|
||||
'admin.remnawave.geoCheck.runningHint',
|
||||
'The node is testing its connection — this usually takes up to a minute.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.phase === 'error' && (
|
||||
<div className="flex flex-col items-center gap-3 px-4 py-8 text-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-2xl bg-error-500/15 text-error-400">
|
||||
<WarningIcon className="h-6 w-6" />
|
||||
</span>
|
||||
<p className="text-sm font-medium text-dark-100">
|
||||
{t('admin.remnawave.geoCheck.error.title', 'Check failed')}
|
||||
</p>
|
||||
<p className="max-w-sm break-words text-xs text-dark-400">{errorText}</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<button type="button" onClick={job.reset} className="btn-secondary">
|
||||
{t('admin.remnawave.geoCheck.changeRoute', 'Change route')}
|
||||
</button>
|
||||
<button type="button" onClick={job.retry} className="btn-primary">
|
||||
{t('admin.remnawave.geoCheck.rerun', 'Run again')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{job.phase === 'done' && job.result && (
|
||||
<GeoCheckReport
|
||||
result={job.result}
|
||||
nodeName={node.name}
|
||||
fullscreen={fullscreen}
|
||||
canFullscreen={canFullscreen}
|
||||
canDownload={canDownload}
|
||||
onToggleFullscreen={() => setFullscreenRequested((v) => !v)}
|
||||
onRerun={job.retry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
231
src/components/admin/remnawave/GeoCheckReport.tsx
Normal file
231
src/components/admin/remnawave/GeoCheckReport.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { GeoCheckResult } from '@/api/adminRemnawave';
|
||||
import {
|
||||
CheckIcon,
|
||||
CodeIcon,
|
||||
CollapseIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
ExpandIcon,
|
||||
EyeIcon,
|
||||
RefreshIcon,
|
||||
} from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyToClipboard } from '@/utils/clipboard';
|
||||
import { GeoCheckImageViewer } from './GeoCheckImageViewer';
|
||||
|
||||
type ReportTab = 'report' | 'json';
|
||||
|
||||
const TOOLBAR_BUTTON =
|
||||
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-dark-800 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-40';
|
||||
|
||||
interface GeoCheckReportProps {
|
||||
result: GeoCheckResult;
|
||||
nodeName: string;
|
||||
fullscreen: boolean;
|
||||
/**
|
||||
* Показывать ли переключатель полного экрана. В Telegram Mini App окно и так
|
||||
* во весь экран — режим лишь снимал отступы и лез под хром Telegram.
|
||||
*/
|
||||
canFullscreen: boolean;
|
||||
/**
|
||||
* Показывать ли скачивание. В webview Telegram атрибут `download`
|
||||
* игнорируется: вместо сохранения файла webview уходит на blob и подменяет
|
||||
* собой Mini App, вернуться можно только кнопкой «Назад». Штатный
|
||||
* `downloadFile` из Bot API 8.0 тут не помогает — он требует HTTPS-ссылку
|
||||
* на файл, а отчёт приходит base64 внутри JSON.
|
||||
*/
|
||||
canDownload: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
onRerun: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Готовый отчёт GeoCheck: картинка либо тот же отчёт в JSON.
|
||||
*
|
||||
* Картинка вставляется как `<img src="data:...">`, а не сырым SVG в разметку:
|
||||
* отчёт несёт встроенный моноширинный шрифт и `<style>`, на которых держится
|
||||
* выравнивание колонок, — санитайзер их выбросит и таблицы поедут. В `<img>`
|
||||
* SVG рисуется в изолированном контексте без скриптов и внешних запросов, то
|
||||
* есть это и безопаснее, и вернее по виду.
|
||||
*/
|
||||
export function GeoCheckReport({
|
||||
result,
|
||||
nodeName,
|
||||
fullscreen,
|
||||
canFullscreen,
|
||||
canDownload,
|
||||
onToggleFullscreen,
|
||||
onRerun,
|
||||
}: GeoCheckReportProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<ReportTab>('report');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const imageSrc = useMemo(() => {
|
||||
const image = result.image;
|
||||
if (!image?.data) return null;
|
||||
return `data:${image.media_type};${image.encoding},${image.data}`;
|
||||
}, [result.image]);
|
||||
|
||||
const reportJson = useMemo(
|
||||
() => (result.raw_report ? JSON.stringify(result.raw_report, null, 2) : null),
|
||||
[result.raw_report],
|
||||
);
|
||||
|
||||
const imageAlt = t('admin.remnawave.geoCheck.reportAlt', 'GeoCheck report for {{node}}', {
|
||||
node: nodeName,
|
||||
});
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!reportJson) return;
|
||||
await copyToClipboard(reportJson);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const image = result.image;
|
||||
if (!image?.data) return;
|
||||
// Blob, а не прямая ссылка на data: URL — во встроенном webview Telegram
|
||||
// скачивание по data: молча ничего не делает.
|
||||
const binary = atob(image.data);
|
||||
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: image.media_type }));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `geocheck-${nodeName.replace(/[^\w.-]+/g, '-').toLowerCase()}.svg`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const tabs: Array<{ id: ReportTab; label: string; icon: ReactNode; enabled: boolean }> = [
|
||||
{
|
||||
id: 'report',
|
||||
label: t('admin.remnawave.geoCheck.tab.report', 'Report'),
|
||||
icon: <EyeIcon className="h-3.5 w-3.5" />,
|
||||
enabled: Boolean(imageSrc),
|
||||
},
|
||||
{
|
||||
id: 'json',
|
||||
label: t('admin.remnawave.geoCheck.tab.json', 'JSON'),
|
||||
icon: <CodeIcon className="h-3.5 w-3.5" />,
|
||||
enabled: Boolean(reportJson),
|
||||
},
|
||||
];
|
||||
|
||||
const fullscreenLabel = fullscreen
|
||||
? t('admin.remnawave.geoCheck.exitFullscreen', 'Exit fullscreen')
|
||||
: t('admin.remnawave.geoCheck.fullscreen', 'Fullscreen');
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex gap-1 rounded-xl bg-dark-800/50 p-1">
|
||||
{tabs
|
||||
.filter((item) => item.enabled)
|
||||
.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setTab(item.id)}
|
||||
aria-pressed={tab === item.id}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
tab === item.id
|
||||
? 'bg-accent-500/20 text-accent-400'
|
||||
: 'text-dark-400 hover:bg-dark-700/50 hover:text-dark-200',
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{canFullscreen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleFullscreen}
|
||||
disabled={!imageSrc}
|
||||
className={TOOLBAR_BUTTON}
|
||||
title={fullscreenLabel}
|
||||
aria-label={fullscreenLabel}
|
||||
aria-pressed={fullscreen}
|
||||
>
|
||||
{fullscreen ? (
|
||||
<CollapseIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ExpandIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
disabled={!reportJson}
|
||||
className={TOOLBAR_BUTTON}
|
||||
title={t('admin.remnawave.geoCheck.copyJson', 'Copy JSON report')}
|
||||
aria-label={t('admin.remnawave.geoCheck.copyJson', 'Copy JSON report')}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="h-4 w-4 text-success-400" />
|
||||
) : (
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{canDownload && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
disabled={!imageSrc}
|
||||
className={TOOLBAR_BUTTON}
|
||||
title={t('admin.remnawave.geoCheck.download', 'Download SVG')}
|
||||
aria-label={t('admin.remnawave.geoCheck.download', 'Download SVG')}
|
||||
>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRerun}
|
||||
className={TOOLBAR_BUTTON}
|
||||
title={t('admin.remnawave.geoCheck.rerun', 'Run again')}
|
||||
aria-label={t('admin.remnawave.geoCheck.rerun', 'Run again')}
|
||||
>
|
||||
<RefreshIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.message && (
|
||||
<p className="mt-3 rounded-xl bg-warning-500/10 px-3 py-2 text-xs text-warning-400">
|
||||
{result.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Высота задана явно: без неё зум-поверхности не от чего отталкиваться,
|
||||
а модалка растягивалась бы под натуральный размер отчёта. */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative mt-3 overflow-hidden rounded-2xl border border-dark-700 bg-dark-950 [contain:paint]',
|
||||
fullscreen ? 'min-h-0 flex-1' : 'h-[62dvh] min-h-[16rem]',
|
||||
)}
|
||||
>
|
||||
{tab === 'report' && imageSrc && (
|
||||
<GeoCheckImageViewer src={imageSrc} alt={imageAlt} fullscreen={fullscreen} />
|
||||
)}
|
||||
{tab === 'json' && reportJson && (
|
||||
<pre className="h-full overflow-auto p-3 font-mono text-[11px] leading-relaxed text-dark-200">
|
||||
{reportJson}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
src/components/admin/remnawave/GeoCheckSetup.tsx
Normal file
152
src/components/admin/remnawave/GeoCheckSetup.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NodeInfo } from '@/api/adminRemnawave';
|
||||
import { GlobeIcon, NetworkIcon, SparklesIcon } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
isRouteReady,
|
||||
suggestedInterfaces,
|
||||
suggestedIps,
|
||||
type GeoCheckRouteMode,
|
||||
} from './geoCheckRoute';
|
||||
|
||||
interface GeoCheckSetupProps {
|
||||
node: NodeInfo;
|
||||
mode: GeoCheckRouteMode;
|
||||
value: string;
|
||||
onModeChange: (mode: GeoCheckRouteMode) => void;
|
||||
onValueChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/** Выбор маршрута, с которого гнать проверку: по умолчанию / IP / интерфейс. */
|
||||
export function GeoCheckSetup({
|
||||
node,
|
||||
mode,
|
||||
value,
|
||||
onModeChange,
|
||||
onValueChange,
|
||||
}: GeoCheckSetupProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const modes: Array<{ id: GeoCheckRouteMode; label: string }> = [
|
||||
{ id: 'default', label: t('admin.remnawave.geoCheck.mode.default', 'Default') },
|
||||
{ id: 'ip', label: t('admin.remnawave.geoCheck.mode.ip', 'IP address') },
|
||||
{ id: 'interface', label: t('admin.remnawave.geoCheck.mode.interface', 'Interface') },
|
||||
];
|
||||
|
||||
const ips = suggestedIps(node);
|
||||
const interfaces = suggestedInterfaces(node);
|
||||
const suggestions = mode === 'ip' ? ips : mode === 'interface' ? interfaces : [];
|
||||
|
||||
const hint =
|
||||
mode === 'default'
|
||||
? t('admin.remnawave.geoCheck.hint.default', 'The node will use its default outbound route.')
|
||||
: mode === 'ip'
|
||||
? t(
|
||||
'admin.remnawave.geoCheck.hint.ip',
|
||||
'Pick one of the node addresses or type any address.',
|
||||
)
|
||||
: t(
|
||||
'admin.remnawave.geoCheck.hint.interface',
|
||||
'Pick one of the node interfaces or type any name.',
|
||||
);
|
||||
|
||||
const placeholder =
|
||||
mode === 'default'
|
||||
? t('admin.remnawave.geoCheck.placeholder.default', 'Automatic')
|
||||
: mode === 'ip'
|
||||
? '1.2.3.4'
|
||||
: 'eth0';
|
||||
|
||||
const isInvalid = mode !== 'default' && value.trim().length > 0 && !isRouteReady(mode, value);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t('admin.remnawave.geoCheck.mode.legend', 'Check route')}
|
||||
className="flex gap-1 rounded-xl bg-dark-800/50 p-1"
|
||||
>
|
||||
{modes.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === item.id}
|
||||
onClick={() => onModeChange(item.id)}
|
||||
className={cn(
|
||||
'flex-1 whitespace-nowrap rounded-lg px-2 py-2 text-xs font-medium transition-colors sm:px-3 sm:text-sm',
|
||||
mode === item.id
|
||||
? 'bg-accent-500/20 text-accent-400'
|
||||
: 'text-dark-400 hover:bg-dark-700/50 hover:text-dark-200',
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="geocheck-route-value" className="block text-sm font-medium text-dark-200">
|
||||
{modes.find((item) => item.id === mode)?.label}
|
||||
</label>
|
||||
<p className="mt-0.5 text-xs text-dark-400">{hint}</p>
|
||||
|
||||
<div className="relative mt-2">
|
||||
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
||||
{mode === 'default' ? (
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
) : mode === 'ip' ? (
|
||||
<GlobeIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<NetworkIcon className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
id="geocheck-route-value"
|
||||
type="text"
|
||||
inputMode={mode === 'ip' ? 'numeric' : 'text'}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
disabled={mode === 'default'}
|
||||
value={mode === 'default' ? '' : value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-invalid={isInvalid}
|
||||
className={cn(
|
||||
'input pl-9 font-mono disabled:cursor-not-allowed disabled:opacity-60',
|
||||
isInvalid && 'input-error',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isInvalid && (
|
||||
<p className="mt-1.5 text-xs text-error-400">
|
||||
{mode === 'ip'
|
||||
? t('admin.remnawave.geoCheck.invalidIp', 'Enter a valid IPv4 or IPv6 address')
|
||||
: t('admin.remnawave.geoCheck.invalidInterface', 'Enter a valid interface name')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{suggestions.length > 0 && (
|
||||
<div className="mt-2.5 flex flex-wrap gap-1.5">
|
||||
{suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
onClick={() => onValueChange(suggestion)}
|
||||
className={cn(
|
||||
'max-w-full truncate rounded-lg px-2 py-1 font-mono text-[11px] transition-colors',
|
||||
value.trim() === suggestion
|
||||
? 'bg-accent-500/20 text-accent-300'
|
||||
: 'bg-dark-700/60 text-dark-300 hover:bg-dark-700 hover:text-dark-100',
|
||||
)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
src/components/admin/remnawave/geoCheckRoute.test.ts
Normal file
134
src/components/admin/remnawave/geoCheckRoute.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { NodeInfo } from '@/api/adminRemnawave';
|
||||
import {
|
||||
buildGeoCheckRequest,
|
||||
isRouteReady,
|
||||
isValidInterfaceName,
|
||||
isValidIpAddress,
|
||||
suggestedInterfaces,
|
||||
suggestedIps,
|
||||
} from './geoCheckRoute';
|
||||
|
||||
function node(overrides: Partial<NodeInfo> = {}): NodeInfo {
|
||||
return {
|
||||
uuid: 'n-1',
|
||||
name: 'Germany #2',
|
||||
address: '213.176.77.249',
|
||||
is_connected: true,
|
||||
is_disabled: false,
|
||||
is_node_online: true,
|
||||
is_xray_running: true,
|
||||
users_online: 10,
|
||||
xray_uptime: 0,
|
||||
is_traffic_tracking_active: false,
|
||||
consumption_multiplier: 1,
|
||||
...overrides,
|
||||
} as NodeInfo;
|
||||
}
|
||||
|
||||
describe('isValidIpAddress', () => {
|
||||
it.each([
|
||||
'1.2.3.4',
|
||||
'213.176.77.249',
|
||||
'0.0.0.0',
|
||||
'255.255.255.255',
|
||||
'2a0b:4141:820:140d::2',
|
||||
])('принимает %s', (value) => expect(isValidIpAddress(value)).toBe(true));
|
||||
|
||||
it.each([
|
||||
'',
|
||||
' ',
|
||||
'not-an-ip',
|
||||
'256.1.1.1',
|
||||
'1.2.3',
|
||||
'1.2.3.4/24',
|
||||
'1.2.3.4 ; reboot',
|
||||
])('отклоняет %s', (value) => expect(isValidIpAddress(value)).toBe(false));
|
||||
|
||||
it('игнорирует обрамляющие пробелы', () => {
|
||||
expect(isValidIpAddress(' 1.2.3.4 ')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidInterfaceName', () => {
|
||||
it.each(['eth0', 'ens3', 'wg0', 'br-lan', 'enp0s31f6'])('принимает %s', (value) =>
|
||||
expect(isValidInterfaceName(value)).toBe(true));
|
||||
|
||||
it.each([
|
||||
'',
|
||||
'bad iface',
|
||||
'eth0;reboot',
|
||||
'../etc',
|
||||
'-eth0',
|
||||
'a'.repeat(33),
|
||||
])('отклоняет %s', (value) => expect(isValidInterfaceName(value)).toBe(false));
|
||||
});
|
||||
|
||||
describe('isRouteReady', () => {
|
||||
it('режим по умолчанию не требует значения', () => {
|
||||
expect(isRouteReady('default', '')).toBe(true);
|
||||
});
|
||||
|
||||
it('режимы ip и interface требуют корректного значения', () => {
|
||||
expect(isRouteReady('ip', '')).toBe(false);
|
||||
expect(isRouteReady('ip', '1.2.3.4')).toBe(true);
|
||||
expect(isRouteReady('interface', '')).toBe(false);
|
||||
expect(isRouteReady('interface', 'ens3')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGeoCheckRequest', () => {
|
||||
it('по умолчанию отправляет пустое тело', () => {
|
||||
expect(buildGeoCheckRequest('default', 'ens3')).toEqual({});
|
||||
});
|
||||
|
||||
it('отправляет ровно одно поле — иначе панель получит неоднозначный запрос', () => {
|
||||
expect(buildGeoCheckRequest('ip', ' 1.2.3.4 ')).toEqual({ ip: '1.2.3.4' });
|
||||
expect(buildGeoCheckRequest('interface', ' ens3 ')).toEqual({ interface: 'ens3' });
|
||||
});
|
||||
|
||||
it('пустое значение в режиме ip/interface означает маршрут по умолчанию', () => {
|
||||
expect(buildGeoCheckRequest('ip', ' ')).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestedIps', () => {
|
||||
it('ставит адрес ноды первым и не дублирует его', () => {
|
||||
const result = suggestedIps(
|
||||
node({
|
||||
ips: [
|
||||
{ ip: '213.176.77.249', status: 'OUTBOUND' },
|
||||
{ ip: '2a0b:4141:820:140d::2', status: 'INBOUND' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(['213.176.77.249', '2a0b:4141:820:140d::2']);
|
||||
});
|
||||
|
||||
it('не предлагает заблокированные адреса', () => {
|
||||
const result = suggestedIps(node({ ips: [{ ip: '10.0.0.1', status: 'BLOCKED' }] }));
|
||||
expect(result).toEqual(['213.176.77.249']);
|
||||
});
|
||||
|
||||
it('работает, когда панель не прислала ips', () => {
|
||||
expect(suggestedIps(node())).toEqual(['213.176.77.249']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestedInterfaces', () => {
|
||||
it('отбрасывает loopback', () => {
|
||||
const result = suggestedInterfaces(
|
||||
node({
|
||||
system: {
|
||||
info: { networkInterfaces: ['lo', 'ens3', 'wg0'] },
|
||||
stats: {},
|
||||
} as unknown as NodeInfo['system'],
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(['ens3', 'wg0']);
|
||||
});
|
||||
|
||||
it('без системной информации — пусто', () => {
|
||||
expect(suggestedInterfaces(node())).toEqual([]);
|
||||
});
|
||||
});
|
||||
55
src/components/admin/remnawave/geoCheckRoute.ts
Normal file
55
src/components/admin/remnawave/geoCheckRoute.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { GeoCheckRequest, NodeInfo } from '@/api/adminRemnawave';
|
||||
|
||||
/** Откуда гнать проверку. */
|
||||
export type GeoCheckRouteMode = 'default' | 'ip' | 'interface';
|
||||
|
||||
/**
|
||||
* Те же правила, что и на бэкенде (`GeocheckRequest`): значение уезжает в
|
||||
* панель и дальше на узел, поэтому форма не должна пропускать мусор. Здесь
|
||||
* проверка нужна не ради безопасности — она ради того, чтобы админ увидел
|
||||
* ошибку сразу, а не через полминуты ожидания.
|
||||
*/
|
||||
const IPV4_RE = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
|
||||
const IPV6_RE = /^[0-9a-fA-F:]+$/;
|
||||
const INTERFACE_RE = /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,31}$/;
|
||||
|
||||
export function isValidIpAddress(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
if (IPV4_RE.test(trimmed)) return true;
|
||||
// IPv6 без полного разбора: достаточно набора символов и одного «::»-стиля
|
||||
// разделителя. Точный разбор делает панель, здесь важно отсечь опечатки.
|
||||
return trimmed.includes(':') && IPV6_RE.test(trimmed) && !trimmed.includes(':::');
|
||||
}
|
||||
|
||||
export function isValidInterfaceName(value: string): boolean {
|
||||
return INTERFACE_RE.test(value.trim());
|
||||
}
|
||||
|
||||
/** Готово ли значение к отправке в выбранном режиме. */
|
||||
export function isRouteReady(mode: GeoCheckRouteMode, value: string): boolean {
|
||||
if (mode === 'default') return true;
|
||||
if (mode === 'ip') return isValidIpAddress(value);
|
||||
return isValidInterfaceName(value);
|
||||
}
|
||||
|
||||
/** Тело запроса для выбранного режима. */
|
||||
export function buildGeoCheckRequest(mode: GeoCheckRouteMode, value: string): GeoCheckRequest {
|
||||
const trimmed = value.trim();
|
||||
if (mode === 'ip' && trimmed) return { ip: trimmed };
|
||||
if (mode === 'interface' && trimmed) return { interface: trimmed };
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Адреса узла, которые имеет смысл предлагать как исходные. */
|
||||
export function suggestedIps(node: NodeInfo): string[] {
|
||||
const fromPanel = (node.ips ?? []).filter((entry) => entry.status !== 'BLOCKED').map((e) => e.ip);
|
||||
// Адрес самой ноды панель в `ips` не всегда повторяет, а он самый ожидаемый.
|
||||
const all = [node.address, ...fromPanel].filter(Boolean);
|
||||
return Array.from(new Set(all));
|
||||
}
|
||||
|
||||
/** Сетевые интерфейсы узла; служебный loopback предлагать бессмысленно. */
|
||||
export function suggestedInterfaces(node: NodeInfo): string[] {
|
||||
return (node.system?.info?.networkInterfaces ?? []).filter((name) => name !== 'lo');
|
||||
}
|
||||
121
src/components/admin/remnawave/useGeoCheckJob.ts
Normal file
121
src/components/admin/remnawave/useGeoCheckJob.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { adminRemnawaveApi } from '@/api/adminRemnawave';
|
||||
import type { GeoCheckRequest, GeoCheckResult } from '@/api/adminRemnawave';
|
||||
import { getApiErrorMessage } from '@/utils/api-error';
|
||||
|
||||
/** Как часто спрашивать панель о готовности отчёта. */
|
||||
const POLL_INTERVAL_MS = 2500;
|
||||
/** Нода отвечает до минуты; после этого порога считаем, что не дождались. */
|
||||
const POLL_TIMEOUT_MS = 180_000;
|
||||
|
||||
export type GeoCheckPhase = 'idle' | 'running' | 'done' | 'error';
|
||||
|
||||
/**
|
||||
* Почему не получилось:
|
||||
* - `request` — панель не приняла запуск (старая версия, нода офлайн, нет прав);
|
||||
* - `timeout` — задача принята, но результата не дождались;
|
||||
* - `failed` — проверка завершилась неудачей, подробности в `message`.
|
||||
*/
|
||||
export interface GeoCheckError {
|
||||
kind: 'request' | 'timeout' | 'failed';
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface GeoCheckJob {
|
||||
phase: GeoCheckPhase;
|
||||
result: GeoCheckResult | null;
|
||||
error: GeoCheckError | null;
|
||||
/** Запускает проверку; повторный вызов забывает прошлую задачу. */
|
||||
start: (body: GeoCheckRequest) => void;
|
||||
/** Повторяет проверку с теми же параметрами маршрута. */
|
||||
retry: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Жизненный цикл одной проверки GeoCheck.
|
||||
*
|
||||
* Проверка асинхронная: POST отдаёт `job_id`, дальше опрашиваем статус, пока
|
||||
* задача не завершится или не выйдет время. Таймаут обязателен — иначе
|
||||
* оставленная открытой модалка будет опрашивать панель бесконечно.
|
||||
*/
|
||||
export function useGeoCheckJob(nodeUuid: string): GeoCheckJob {
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [requestError, setRequestError] = useState<string | null>(null);
|
||||
const lastRequest = useRef<GeoCheckRequest>({});
|
||||
const startedAt = useRef(0);
|
||||
|
||||
const startMutation = useMutation({
|
||||
mutationFn: (body: GeoCheckRequest) => adminRemnawaveApi.startNodeGeoCheck(nodeUuid, body),
|
||||
onSuccess: (data) => {
|
||||
startedAt.current = Date.now();
|
||||
setJobId(data.job_id);
|
||||
},
|
||||
onError: (error) => setRequestError(getApiErrorMessage(error, '')),
|
||||
});
|
||||
|
||||
const jobQuery = useQuery({
|
||||
queryKey: ['admin-remnawave-geocheck', jobId],
|
||||
queryFn: () => adminRemnawaveApi.getGeoCheckJob(jobId as string),
|
||||
enabled: Boolean(jobId),
|
||||
gcTime: 0,
|
||||
refetchInterval: (query) => {
|
||||
if (Date.now() - startedAt.current > POLL_TIMEOUT_MS) return false;
|
||||
const data = query.state.data;
|
||||
if (!data) return POLL_INTERVAL_MS;
|
||||
return data.is_completed || data.is_failed ? false : POLL_INTERVAL_MS;
|
||||
},
|
||||
});
|
||||
|
||||
const start = useCallback(
|
||||
(body: GeoCheckRequest) => {
|
||||
lastRequest.current = body;
|
||||
setRequestError(null);
|
||||
setJobId(null);
|
||||
startMutation.mutate(body);
|
||||
},
|
||||
[startMutation],
|
||||
);
|
||||
|
||||
const retry = useCallback(() => start(lastRequest.current), [start]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setJobId(null);
|
||||
setRequestError(null);
|
||||
startMutation.reset();
|
||||
}, [startMutation]);
|
||||
|
||||
const job = jobQuery.data;
|
||||
const settled = Boolean(job?.is_completed || job?.is_failed);
|
||||
const timedOut = Boolean(jobId) && !settled && Date.now() - startedAt.current > POLL_TIMEOUT_MS;
|
||||
// `is_failed` от панели и `success: false` внутри результата — разные вещи:
|
||||
// первое значит «задача упала», второе — «нода ответила, но проверка не
|
||||
// удалась». Админу в обоих случаях нужен `result.message`.
|
||||
const failed = Boolean(job?.is_failed || (settled && job?.result && !job.result.success));
|
||||
|
||||
let phase: GeoCheckPhase = 'idle';
|
||||
let error: GeoCheckError | null = null;
|
||||
|
||||
if (requestError !== null) {
|
||||
phase = 'error';
|
||||
error = { kind: 'request', message: requestError || null };
|
||||
} else if (timedOut) {
|
||||
phase = 'error';
|
||||
error = { kind: 'timeout', message: null };
|
||||
} else if (startMutation.isPending || (jobId && !settled)) {
|
||||
phase = 'running';
|
||||
} else if (settled) {
|
||||
phase = failed ? 'error' : 'done';
|
||||
if (failed) error = { kind: 'failed', message: job?.result?.message ?? null };
|
||||
}
|
||||
|
||||
return {
|
||||
phase,
|
||||
result: job?.result ?? null,
|
||||
error,
|
||||
start,
|
||||
retry,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -131,6 +131,7 @@ export interface SubscriptionTabProps {
|
||||
onRemoveTraffic: (purchaseId: number) => Promise<void>;
|
||||
onResetDevices: () => Promise<void>;
|
||||
onCancelSbpRecurring: () => Promise<void>;
|
||||
onDeleteSubscription: () => Promise<void>;
|
||||
onDeleteDevice: (hwid: string) => Promise<void>;
|
||||
onRenameDevice: (hwid: string) => Promise<void>;
|
||||
onLoadDevices: () => Promise<void>;
|
||||
@@ -195,6 +196,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
|
||||
onRemoveTraffic,
|
||||
onResetDevices,
|
||||
onCancelSbpRecurring,
|
||||
onDeleteSubscription,
|
||||
onDeleteDevice,
|
||||
onRenameDevice,
|
||||
onLoadDevices,
|
||||
@@ -429,6 +431,41 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete this subscription — in multi-tariff mode spent trials
|
||||
pile up in the card, and removing one used to be possible
|
||||
only through the bulk-actions screen. */}
|
||||
{hasPermission('users:subscription') && (
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-dark-200">
|
||||
{t('admin.users.detail.subscription.deleteTitle')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-400">
|
||||
{t('admin.users.detail.subscription.deleteHint')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
// Per-subscription confirm key: an armed confirm must not
|
||||
// survive switching to another subscription in the picker.
|
||||
onClick={() =>
|
||||
onInlineConfirm(`deleteSubscription_${selectedSub.id}`, onDeleteSubscription)
|
||||
}
|
||||
disabled={actionLoading}
|
||||
className={`shrink-0 rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
|
||||
confirmingAction === `deleteSubscription_${selectedSub.id}`
|
||||
? 'bg-error-500 text-white'
|
||||
: 'bg-error-500/15 text-error-400 hover:bg-error-500/25'
|
||||
}`}
|
||||
>
|
||||
{confirmingAction === `deleteSubscription_${selectedSub.id}`
|
||||
? t('admin.users.detail.actions.areYouSure')
|
||||
: t('admin.users.detail.subscription.deleteButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Traffic Packages */}
|
||||
{selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
|
||||
@@ -151,16 +151,16 @@ export function SyncTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UUID info */}
|
||||
{/* Panel identity */}
|
||||
<div className="rounded-xl bg-dark-800/50 p-4">
|
||||
{syncStatus?.subscription_tariff_name && (
|
||||
<div className="mb-2 text-xs text-dark-500">{syncStatus.subscription_tariff_name}</div>
|
||||
)}
|
||||
<div className="mb-1 text-sm text-dark-400">Remnawave UUID</div>
|
||||
<div className="mb-1 text-sm text-dark-400">Remnawave ID</div>
|
||||
<div className="break-all font-mono text-sm text-dark-100">
|
||||
{syncStatus?.remnawave_uuid ||
|
||||
user.remnawave_uuid ||
|
||||
t('admin.users.detail.sync.notLinked')}
|
||||
{/* Remnawave 3.0.0 identifies a panel user by a numeric id, so 0 is not
|
||||
a valid id but ?? would still render it — check for null explicitly. */}
|
||||
{syncStatus?.remnawave_id ?? user.remnawave_id ?? t('admin.users.detail.sync.notLinked')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -318,7 +318,7 @@ function ChatView({
|
||||
</div>
|
||||
{msg.message_text && (
|
||||
<p
|
||||
className="whitespace-pre-wrap text-sm text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
|
||||
className="whitespace-pre-wrap break-words text-sm text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
|
||||
dangerouslySetInnerHTML={{ __html: linkifyText(msg.message_text) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
124
src/components/broadcasts/BroadcastDeliveryStats.tsx
Normal file
124
src/components/broadcasts/BroadcastDeliveryStats.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatCard } from '@/components/stats';
|
||||
import { BanIcon, SendIcon, UsersIcon, XCircleIcon } from '@/components/icons';
|
||||
import { isBroadcastInFlight } from '../../utils/broadcastStatus';
|
||||
|
||||
const statusConfig: Record<string, { bg: string; text: string; labelKey: string }> = {
|
||||
queued: {
|
||||
bg: 'bg-warning-500/20',
|
||||
text: 'text-warning-400',
|
||||
labelKey: 'admin.broadcasts.status.queued',
|
||||
},
|
||||
in_progress: {
|
||||
bg: 'bg-accent-500/20',
|
||||
text: 'text-accent-400',
|
||||
labelKey: 'admin.broadcasts.status.inProgress',
|
||||
},
|
||||
completed: {
|
||||
bg: 'bg-success-500/20',
|
||||
text: 'text-success-400',
|
||||
labelKey: 'admin.broadcasts.status.completed',
|
||||
},
|
||||
partial: {
|
||||
bg: 'bg-warning-500/20',
|
||||
text: 'text-warning-400',
|
||||
labelKey: 'admin.broadcasts.status.partial',
|
||||
},
|
||||
failed: {
|
||||
bg: 'bg-error-500/20',
|
||||
text: 'text-error-400',
|
||||
labelKey: 'admin.broadcasts.status.failed',
|
||||
},
|
||||
cancelled: {
|
||||
bg: 'bg-dark-500/20',
|
||||
text: 'text-dark-400',
|
||||
labelKey: 'admin.broadcasts.status.cancelled',
|
||||
},
|
||||
cancelling: {
|
||||
bg: 'bg-warning-500/20',
|
||||
text: 'text-warning-400',
|
||||
labelKey: 'admin.broadcasts.status.cancelling',
|
||||
},
|
||||
};
|
||||
|
||||
export function BroadcastStatusBadge({ status }: { status: string }) {
|
||||
const { t } = useTranslation();
|
||||
const config = statusConfig[status] || statusConfig.queued;
|
||||
return (
|
||||
<span className={`rounded-full px-3 py-1 text-sm font-medium ${config.bg} ${config.text}`}>
|
||||
{t(config.labelKey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface BroadcastDeliveryStatsProps {
|
||||
status: string;
|
||||
progressPercent: number;
|
||||
totalCount: number;
|
||||
sentCount: number;
|
||||
blockedCount: number;
|
||||
failedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Прогресс доставки и счётчики: сколько ушло, сколько заблокировало бота, сколько
|
||||
* не доехало. Используется и на странице рассылки, и на отправке промопредложения.
|
||||
*/
|
||||
export function BroadcastDeliveryStats({
|
||||
status,
|
||||
progressPercent,
|
||||
totalCount,
|
||||
sentCount,
|
||||
blockedCount,
|
||||
failedCount,
|
||||
}: BroadcastDeliveryStatsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{isBroadcastInFlight(status) && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
|
||||
<span className="font-medium text-dark-100">{progressPercent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.total')}
|
||||
value={totalCount}
|
||||
icon={<UsersIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.sent')}
|
||||
value={sentCount}
|
||||
icon={<SendIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.blocked')}
|
||||
value={blockedCount}
|
||||
icon={<BanIcon className="h-5 w-5" />}
|
||||
tone="warning"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.failed')}
|
||||
value={failedCount}
|
||||
icon={<XCircleIcon className="h-5 w-5" />}
|
||||
tone="error"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BroadcastDeliveryStats;
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
retrieveLaunchParams,
|
||||
} from '@telegram-apps/sdk-react';
|
||||
import { blockButtonClass } from './blocks/buttonStyles';
|
||||
import { loadHtml5Qrcode, type Html5QrcodeInstance } from '@/utils/qrScanner';
|
||||
|
||||
const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']);
|
||||
|
||||
@@ -19,24 +20,12 @@ function isTelegramMobile(): boolean {
|
||||
}
|
||||
|
||||
const HAPP_TV_API = 'https://check.happ.su/sendtv';
|
||||
const HTML5_QRCODE_CDN = 'https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js';
|
||||
|
||||
interface Props {
|
||||
subscriptionUrl: string;
|
||||
isLight: boolean;
|
||||
}
|
||||
|
||||
interface Html5QrcodeInstance {
|
||||
start: (
|
||||
cameraIdOrConfig: { facingMode: string } | string,
|
||||
config: { fps: number; qrbox: { width: number; height: number } },
|
||||
onSuccess: (decoded: string) => void,
|
||||
onError: () => void,
|
||||
) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [code, setCode] = useState('');
|
||||
@@ -158,24 +147,16 @@ export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode
|
||||
type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance };
|
||||
const w = window as WindowWithHtml5;
|
||||
if (!w.Html5Qrcode) {
|
||||
const script = document.createElement('script');
|
||||
script.src = HTML5_QRCODE_CDN;
|
||||
document.head.appendChild(script);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject();
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
if (!w.Html5Qrcode) {
|
||||
// Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode.
|
||||
// Загрузка вынесена в общий хелпер — он подключает скрипт с проверкой
|
||||
// целостности (SRI), чтобы подмена на стороне CDN не исполнилась в кабинете.
|
||||
const Html5Qrcode = await loadHtml5Qrcode();
|
||||
if (!Html5Qrcode) {
|
||||
showToast(t('subscription.tvQuickConnect.noCamera'), 'error');
|
||||
return;
|
||||
}
|
||||
setScanning(true);
|
||||
const scanner = new w.Html5Qrcode('tv-qr-reader');
|
||||
const scanner = new Html5Qrcode('tv-qr-reader');
|
||||
scannerRef.current = scanner;
|
||||
const config = { fps: 10, qrbox: { width: 220, height: 220 } };
|
||||
try {
|
||||
|
||||
143
src/components/dashboard/ConnectDeviceTile.tsx
Normal file
143
src/components/dashboard/ConnectDeviceTile.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useHaptic } from '../../platform';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { useTrafficZone } from '../../hooks/useTrafficZone';
|
||||
import { getGlassColors } from '../../utils/glassTheme';
|
||||
import { HoverBorderGradient } from '../ui/hover-border-gradient';
|
||||
|
||||
interface ConnectDeviceTileProps {
|
||||
subscription: {
|
||||
id: number;
|
||||
device_limit: number;
|
||||
subscription_url?: string | null;
|
||||
};
|
||||
connectedDevices: number;
|
||||
/** Процент израсходованного трафика — от него зависит акцентный цвет плитки. */
|
||||
usedPercent?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Плитка «Подключить устройство».
|
||||
*
|
||||
* Живёт и в карточке активной подписки, и на главной: пользователь,
|
||||
* которому подписку выдал бонус рекламной кампании, попадает на главную
|
||||
* с готовым доступом — и без этой плитки не понимает, что делать дальше.
|
||||
*/
|
||||
export default function ConnectDeviceTile({
|
||||
subscription,
|
||||
connectedDevices,
|
||||
usedPercent = 0,
|
||||
}: ConnectDeviceTileProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const haptic = useHaptic();
|
||||
const { isDark } = useTheme();
|
||||
const g = getGlassColors(isDark);
|
||||
const zone = useTrafficZone(usedPercent);
|
||||
|
||||
const isAtDeviceLimit =
|
||||
subscription.device_limit > 0 && connectedDevices >= subscription.device_limit;
|
||||
|
||||
if (!subscription.subscription_url) return null;
|
||||
|
||||
return (
|
||||
<HoverBorderGradient
|
||||
as="button"
|
||||
accentColor={zone.mainHex}
|
||||
disabled={isAtDeviceLimit}
|
||||
onClick={() => {
|
||||
if (isAtDeviceLimit) {
|
||||
haptic.notification('error');
|
||||
return;
|
||||
}
|
||||
navigate(`/connection?sub=${subscription.id}`);
|
||||
}}
|
||||
className={`mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300 ${isAtDeviceLimit ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
data-onboarding="connect-devices"
|
||||
style={{ fontFamily: 'inherit' }}
|
||||
>
|
||||
{/* Monitor icon */}
|
||||
<div
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] transition-colors duration-500"
|
||||
style={{ background: `rgba(${zone.mainVarRaw}, 0.07)` }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={zone.mainVar}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<path d="M12 17v4M8 21h8" />
|
||||
<path d="M12 8v4M10 10h4" opacity="0.7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold tracking-tight text-dark-50">
|
||||
{t('dashboard.connectDevice')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||||
{subscription.device_limit === 0
|
||||
? t('dashboard.devicesConnectedUnlimited', { used: connectedDevices })
|
||||
: t('dashboard.devicesOfMax', {
|
||||
used: connectedDevices,
|
||||
max: subscription.device_limit,
|
||||
})}
|
||||
</div>
|
||||
{isAtDeviceLimit && (
|
||||
<div
|
||||
className="mt-1 text-[10px] font-medium"
|
||||
style={{ color: 'rgb(var(--color-warning-400))' }}
|
||||
>
|
||||
{t('dashboard.deviceLimitReached')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device indicator */}
|
||||
{subscription.device_limit === 0 ? (
|
||||
<div className="flex flex-shrink-0 items-center text-lg text-dark-50/40" aria-hidden="true">
|
||||
∞
|
||||
</div>
|
||||
) : subscription.device_limit <= 10 ? (
|
||||
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
|
||||
{Array.from({ length: subscription.device_limit }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[7px] w-[7px] rounded-full transition-all duration-300"
|
||||
style={{
|
||||
background: i < connectedDevices ? zone.mainVar : g.textGhost,
|
||||
boxShadow: i < connectedDevices ? `0 0 6px rgba(${zone.mainVarRaw}, 0.31)` : 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-16 flex-shrink-0 items-center" aria-hidden="true">
|
||||
<div
|
||||
className="h-[6px] w-full overflow-hidden rounded-full"
|
||||
style={{ background: g.textGhost }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.round((connectedDevices / subscription.device_limit) * 100)}%`,
|
||||
background: zone.mainVar,
|
||||
boxShadow: `0 0 8px rgba(${zone.mainVarRaw}, 0.25)`,
|
||||
minWidth: connectedDevices > 0 ? '4px' : '0px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HoverBorderGradient>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
import { uiLocale } from '@/utils/uiLocale';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { Link } from 'react-router';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import TrafficProgressBar from './TrafficProgressBar';
|
||||
import Sparkline from './Sparkline';
|
||||
import ConnectDeviceTile from './ConnectDeviceTile';
|
||||
import { useAnimatedNumber } from '../../hooks/useAnimatedNumber';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { useTrafficZone } from '../../hooks/useTrafficZone';
|
||||
import { formatTraffic } from '../../utils/formatTraffic';
|
||||
import { getGlassColors } from '../../utils/glassTheme';
|
||||
import { HoverBorderGradient } from '../ui/hover-border-gradient';
|
||||
import { CalendarIcon, RefreshIcon } from '@/components/icons';
|
||||
import { useHaptic } from '../../platform';
|
||||
import type { Subscription } from '../../types';
|
||||
|
||||
interface SubscriptionCardActiveProps {
|
||||
@@ -35,7 +33,6 @@ export default function SubscriptionCardActive({
|
||||
connectedDevices,
|
||||
}: SubscriptionCardActiveProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { isDark } = useTheme();
|
||||
const g = getGlassColors(isDark);
|
||||
|
||||
@@ -44,10 +41,6 @@ export default function SubscriptionCardActive({
|
||||
const isUnlimited = trafficData?.is_unlimited ?? subscription.traffic_limit_gb === 0;
|
||||
const zone = useTrafficZone(usedPercent);
|
||||
const animatedPercent = useAnimatedNumber(usedPercent);
|
||||
const haptic = useHaptic();
|
||||
|
||||
const isAtDeviceLimit =
|
||||
subscription.device_limit > 0 && connectedDevices >= subscription.device_limit;
|
||||
|
||||
const formattedDate = new Date(subscription.end_date).toLocaleDateString(uiLocale());
|
||||
const daysLeft = subscription.days_left;
|
||||
@@ -161,109 +154,11 @@ export default function SubscriptionCardActive({
|
||||
</div>
|
||||
|
||||
{/* ─── Connect Device Button ─── */}
|
||||
{subscription.subscription_url && (
|
||||
<HoverBorderGradient
|
||||
as="button"
|
||||
accentColor={zone.mainHex}
|
||||
disabled={isAtDeviceLimit}
|
||||
onClick={() => {
|
||||
if (isAtDeviceLimit) {
|
||||
haptic.notification('error');
|
||||
return;
|
||||
}
|
||||
navigate(`/connection?sub=${subscription.id}`);
|
||||
}}
|
||||
className={`mb-2.5 flex w-full items-center gap-3.5 rounded-[14px] p-3.5 text-left transition-shadow duration-300${isAtDeviceLimit ? 'cursor-not-allowed opacity-50' : ''}`}
|
||||
data-onboarding="connect-devices"
|
||||
style={{ fontFamily: 'inherit' }}
|
||||
>
|
||||
{/* Monitor icon */}
|
||||
<div
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] transition-colors duration-500"
|
||||
style={{ background: `rgba(${zone.mainVarRaw}, 0.07)` }}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={zone.mainVar}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
||||
<path d="M12 17v4M8 21h8" />
|
||||
<path d="M12 8v4M10 10h4" opacity="0.7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold tracking-tight text-dark-50">
|
||||
{t('dashboard.connectDevice')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||||
{subscription.device_limit === 0
|
||||
? t('dashboard.devicesConnectedUnlimited', { used: connectedDevices })
|
||||
: t('dashboard.devicesOfMax', {
|
||||
used: connectedDevices,
|
||||
max: subscription.device_limit,
|
||||
})}
|
||||
</div>
|
||||
{isAtDeviceLimit && (
|
||||
<div
|
||||
className="mt-1 text-[10px] font-medium"
|
||||
style={{ color: 'rgb(var(--color-warning-400))' }}
|
||||
>
|
||||
{t('dashboard.deviceLimitReached')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device indicator */}
|
||||
{subscription.device_limit === 0 ? (
|
||||
<div
|
||||
className="flex flex-shrink-0 items-center text-lg text-dark-50/40"
|
||||
aria-hidden="true"
|
||||
>
|
||||
∞
|
||||
</div>
|
||||
) : subscription.device_limit <= 10 ? (
|
||||
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
|
||||
{Array.from({ length: subscription.device_limit }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-[7px] w-[7px] rounded-full transition-all duration-300"
|
||||
style={{
|
||||
background: i < connectedDevices ? zone.mainVar : g.textGhost,
|
||||
boxShadow:
|
||||
i < connectedDevices ? `0 0 6px rgba(${zone.mainVarRaw}, 0.31)` : 'none',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-16 flex-shrink-0 items-center" aria-hidden="true">
|
||||
<div
|
||||
className="h-[6px] w-full overflow-hidden rounded-full"
|
||||
style={{ background: g.textGhost }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.round((connectedDevices / subscription.device_limit) * 100)}%`,
|
||||
background: zone.mainVar,
|
||||
boxShadow: `0 0 8px rgba(${zone.mainVarRaw}, 0.25)`,
|
||||
minWidth: connectedDevices > 0 ? '4px' : '0px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HoverBorderGradient>
|
||||
)}
|
||||
<ConnectDeviceTile
|
||||
subscription={subscription}
|
||||
connectedDevices={connectedDevices}
|
||||
usedPercent={usedPercent}
|
||||
/>
|
||||
|
||||
{/* ─── Stats row: Tariff + Days Left ─── */}
|
||||
<div className="mb-5 flex gap-2.5">
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import {
|
||||
PiArrowsInSimple,
|
||||
PiArrowsOutSimple,
|
||||
PiCode,
|
||||
PiMapTrifold,
|
||||
PiNetwork,
|
||||
PiSlidersHorizontal,
|
||||
PiWrench,
|
||||
PiBookOpen,
|
||||
@@ -264,6 +269,27 @@ export const ExternalLinkIcon = ({ className }: IconProps) => (
|
||||
<PiArrowSquareOut className={cn('h-5 w-5', className)} />
|
||||
);
|
||||
|
||||
export const CodeIcon = ({ className }: IconProps) => (
|
||||
<PiCode className={cn('h-5 w-5', className)} />
|
||||
);
|
||||
|
||||
export const CollapseIcon = ({ className }: IconProps) => (
|
||||
<PiArrowsInSimple className={cn('h-5 w-5', className)} />
|
||||
);
|
||||
|
||||
export const ExpandIcon = ({ className }: IconProps) => (
|
||||
<PiArrowsOutSimple className={cn('h-5 w-5', className)} />
|
||||
);
|
||||
|
||||
/** GeoCheck — проверка геоданных ноды (Remnawave 3.3.0). */
|
||||
export const GeoCheckIcon = ({ className }: IconProps) => (
|
||||
<PiMapTrifold className={cn('h-5 w-5', className)} />
|
||||
);
|
||||
|
||||
export const NetworkIcon = ({ className }: IconProps) => (
|
||||
<PiNetwork className={cn('h-5 w-5', className)} />
|
||||
);
|
||||
|
||||
export const EyeIcon = ({ className }: IconProps) => <PiEye className={cn('h-5 w-5', className)} />;
|
||||
|
||||
export const FileTextIcon = ({ className }: IconProps) => (
|
||||
|
||||
139
src/components/subscription/DeviceLimitSheet.tsx
Normal file
139
src/components/subscription/DeviceLimitSheet.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { subscriptionApi } from '@/api/subscription';
|
||||
import { DevicesIcon, PlusIcon, TrashIcon, WarningIcon } from '@/components/icons';
|
||||
import { ResponsiveSheet } from '@/components/ui/ResponsiveSheet';
|
||||
import { getApiErrorMessage } from '@/utils/api-error';
|
||||
|
||||
interface DeviceLimitSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
subscriptionId: number;
|
||||
subscriptionName: string;
|
||||
deviceLimit: number;
|
||||
isTrial: boolean;
|
||||
devices: Array<{
|
||||
hwid: string;
|
||||
platform: string;
|
||||
device_model: string;
|
||||
local_name?: string | null;
|
||||
}>;
|
||||
/** Уводит на страницу подписки, где живёт докупка слотов. */
|
||||
onOpenSubscription: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Почему нельзя подключить ещё одно устройство и что с этим делать.
|
||||
*
|
||||
* Открывается из подвала карточки, когда слоты кончились. Заблокировать
|
||||
* кнопку было бы проще, но человек остался бы без выхода: реальных действий
|
||||
* тут два — освободить слот или добавить ещё.
|
||||
*/
|
||||
export function DeviceLimitSheet({
|
||||
isOpen,
|
||||
onClose,
|
||||
subscriptionId,
|
||||
subscriptionName,
|
||||
deviceLimit,
|
||||
isTrial,
|
||||
devices,
|
||||
onOpenSubscription,
|
||||
}: DeviceLimitSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Докупка слотов недоступна на тестовой подписке и при безлимите — ровно
|
||||
// те же условия, что у блока докупки на странице подписки.
|
||||
const canAddSlots = !isTrial && deviceLimit !== 0;
|
||||
|
||||
const disconnect = useMutation({
|
||||
mutationFn: (hwid: string) => subscriptionApi.deleteDevice(hwid, subscriptionId),
|
||||
onSuccess: () => {
|
||||
setError(null);
|
||||
queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] });
|
||||
onClose();
|
||||
},
|
||||
onError: (err) =>
|
||||
setError(getApiErrorMessage(err, t('common.error', 'Не удалось выполнить действие'))),
|
||||
});
|
||||
|
||||
return (
|
||||
<ResponsiveSheet
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={t('subscription.connectFooter.full', 'Все слоты заняты')}
|
||||
>
|
||||
<div className="space-y-4 px-4 pb-6 sm:px-5">
|
||||
<div className="flex items-start gap-3 rounded-2xl bg-warning-400/10 p-3.5">
|
||||
<WarningIcon className="mt-0.5 h-5 w-5 shrink-0 text-warning-400" />
|
||||
<p className="text-[13px] leading-snug text-dark-200">
|
||||
{canAddSlots
|
||||
? t('subscription.connectFooter.limitExplained', {
|
||||
defaultValue:
|
||||
'Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите слот или добавьте ещё.',
|
||||
name: subscriptionName,
|
||||
count: deviceLimit,
|
||||
})
|
||||
: t('subscription.connectFooter.limitExplainedNoTopup', {
|
||||
defaultValue:
|
||||
'Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите занятый слот.',
|
||||
name: subscriptionName,
|
||||
count: deviceLimit,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-[11px] uppercase tracking-wider text-dark-500">
|
||||
{t('subscription.connectFooter.connectedDevices', 'Подключённые устройства')}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{devices.map((device) => (
|
||||
<div
|
||||
key={device.hwid}
|
||||
className="flex items-center gap-3 rounded-xl bg-dark-800/60 px-3 py-2.5"
|
||||
>
|
||||
<DevicesIcon className="h-4 w-4 shrink-0 opacity-40" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] text-dark-100">
|
||||
{device.local_name || device.device_model || device.platform}
|
||||
</div>
|
||||
<div className="text-[11px] text-dark-500">{device.platform}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disconnect.isPending}
|
||||
onClick={() => disconnect.mutate(device.hwid)}
|
||||
className="flex min-h-[36px] shrink-0 items-center gap-1.5 rounded-lg bg-error-500/10 px-2.5 text-[12px] font-medium text-error-400 transition-colors hover:bg-error-500/20 disabled:opacity-50"
|
||||
>
|
||||
<TrashIcon className="h-3.5 w-3.5" />
|
||||
{t('subscription.connectFooter.disconnect', 'Отключить')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-xl bg-error-500/10 px-3 py-2 text-[12px] text-error-400">{error}</p>
|
||||
)}
|
||||
|
||||
{canAddSlots ? (
|
||||
<button type="button" onClick={onOpenSubscription} className="btn-primary w-full">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t('subscription.connectFooter.addSlots', 'Добавить слоты')}
|
||||
</button>
|
||||
) : (
|
||||
<p className="rounded-xl bg-dark-800/60 px-3 py-2.5 text-[12px] leading-snug text-dark-400">
|
||||
{t(
|
||||
'subscription.connectFooter.noTopupHint',
|
||||
'На тестовой подписке слоты докупить нельзя — освободите занятый или оформите платный тариф.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
93
src/components/subscription/SubscriptionConnectFooter.tsx
Normal file
93
src/components/subscription/SubscriptionConnectFooter.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronRightIcon, DevicesIcon, WarningIcon } from '@/components/icons';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ConnectFooterState } from './connectFooterState';
|
||||
|
||||
interface SubscriptionConnectFooterProps {
|
||||
state: ConnectFooterState;
|
||||
/** Цвет разделителя берём у карточки, чтобы подвал читался её частью. */
|
||||
borderColor: string;
|
||||
mutedColor: string;
|
||||
onConnect: () => void;
|
||||
onManage: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подвал карточки подписки: подключить устройство либо разобраться с лимитом.
|
||||
*
|
||||
* Отдельная зона нажатия внутри карточки, поэтому карточка снаружи — не
|
||||
* `<button>`, а обёртка: вложенные кнопки невалидны и ломаются в браузере.
|
||||
*/
|
||||
export function SubscriptionConnectFooter({
|
||||
state,
|
||||
borderColor,
|
||||
mutedColor,
|
||||
onConnect,
|
||||
onManage,
|
||||
}: SubscriptionConnectFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (state.kind === 'hidden') return null;
|
||||
|
||||
if (state.kind === 'loading') {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 border-t px-4 py-2.5"
|
||||
style={{ borderColor }}
|
||||
aria-busy="true"
|
||||
>
|
||||
{/* Высоту задаёт невидимый текст с той же типографикой, что у готового
|
||||
подвала. Подбирать её числом нельзя: строчный бокс даёт дробные
|
||||
пиксели, и карточка дёргается при появлении данных. */}
|
||||
<div className="flex w-full items-center gap-2.5">
|
||||
<div className="h-4 w-4 shrink-0 animate-pulse rounded bg-dark-700/60" />
|
||||
<div className="h-3.5 w-40 animate-pulse rounded bg-dark-700/60" />
|
||||
<div className="ml-auto h-3 w-10 animate-pulse rounded bg-dark-700/60" />
|
||||
<span aria-hidden className="w-0 overflow-hidden text-[13px] font-medium">
|
||||
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isFull = state.kind === 'full';
|
||||
const highlight = state.kind === 'connect' && state.highlight;
|
||||
|
||||
const counter =
|
||||
state.kind === 'connect' && state.unlimited
|
||||
? `${state.used} · ∞`
|
||||
: `${state.used} / ${state.limit}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={isFull ? onManage : onConnect}
|
||||
className="flex w-full items-center gap-2.5 border-t px-4 py-2.5 text-left transition-colors active:bg-accent-500/10"
|
||||
style={{ borderColor }}
|
||||
>
|
||||
{isFull ? (
|
||||
<WarningIcon className="h-4 w-4 shrink-0 text-warning-400/80" />
|
||||
) : (
|
||||
<DevicesIcon
|
||||
className={cn('h-4 w-4 shrink-0', highlight ? 'text-accent-400' : 'opacity-40')}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn('text-[13px] font-medium', highlight && 'text-accent-400')}
|
||||
style={highlight ? undefined : { color: mutedColor }}
|
||||
>
|
||||
{isFull
|
||||
? t('subscription.connectFooter.full', 'Все слоты заняты')
|
||||
: t('subscription.connectFooter.connect', 'Подключить устройство')}
|
||||
</span>
|
||||
<span
|
||||
className={cn('ml-auto text-[11px] tabular-nums', isFull && 'text-warning-400/80')}
|
||||
style={isFull ? undefined : { color: mutedColor }}
|
||||
>
|
||||
{counter}
|
||||
</span>
|
||||
<ChevronRightIcon className="h-3.5 w-3.5 shrink-0 opacity-30" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { getGlassColors } from '../../utils/glassTheme';
|
||||
import { useHaptic } from '../../platform';
|
||||
import { CalendarIcon, CheckIcon, ChevronRightIcon, DevicesIcon } from '@/components/icons';
|
||||
import type { SubscriptionListItem } from '../../types';
|
||||
import { connectFooterState } from './connectFooterState';
|
||||
import { SubscriptionConnectFooter } from './SubscriptionConnectFooter';
|
||||
|
||||
function formatDate(iso: string | null, locale?: string): string {
|
||||
if (!iso) return '—';
|
||||
@@ -68,9 +70,20 @@ function StatusBadge({
|
||||
export default function SubscriptionListCard({
|
||||
subscription,
|
||||
onClick,
|
||||
connect,
|
||||
}: {
|
||||
subscription: SubscriptionListItem;
|
||||
onClick: () => void;
|
||||
/**
|
||||
* Подключение устройства прямо из карточки. Задаётся только на главной:
|
||||
* список «Все подписки» остаётся простым перечнем без действий.
|
||||
*/
|
||||
connect?: {
|
||||
/** `undefined`, пока число устройств не загрузилось. */
|
||||
connectedDevices: number | undefined;
|
||||
onConnect: () => void;
|
||||
onManage: () => void;
|
||||
};
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { isDark } = useTheme();
|
||||
@@ -123,89 +136,113 @@ export default function SubscriptionListCard({
|
||||
: 'rgba(255,59,92,0.03)'
|
||||
: g.cardBg;
|
||||
|
||||
const footer = connect
|
||||
? connectFooterState({
|
||||
status: subscription.status,
|
||||
subscriptionUrl: subscription.subscription_url,
|
||||
deviceLimit: subscription.device_limit,
|
||||
connected: connect.connectedDevices,
|
||||
})
|
||||
: { kind: 'hidden' as const };
|
||||
|
||||
// Подвал — своя зона нажатия, поэтому карточка снаружи не `<button>`:
|
||||
// вложенные кнопки невалидны и ведут себя в браузерах непредсказуемо.
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="w-full rounded-2xl border p-4 text-left transition-all duration-200 hover:scale-[1.01] active:scale-[0.99]"
|
||||
<div
|
||||
className="overflow-hidden rounded-2xl border transition-all duration-200 hover:scale-[1.01] active:scale-[0.99]"
|
||||
style={{ background: bgColor, borderColor }}
|
||||
>
|
||||
{/* Header: tariff name + status badge + chevron */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-base font-semibold" style={{ color: g.text }}>
|
||||
{subscription.tariff_name || t('subscription.defaultName', 'Подписка')}
|
||||
</span>
|
||||
<StatusBadge status={subscription.status} isTrial={isTrial} t={t} />
|
||||
</div>
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 opacity-30" />
|
||||
</div>
|
||||
|
||||
{/* Traffic mini progress bar */}
|
||||
{isActive && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1 flex items-baseline justify-between">
|
||||
<span className="text-[11px] font-medium" style={{ color: g.textSecondary }}>
|
||||
{t('subscription.traffic', 'Трафик')}
|
||||
</span>
|
||||
<span className="text-[11px] tabular-nums" style={{ color: g.textSecondary }}>
|
||||
{isUnlimited
|
||||
? '∞'
|
||||
: `${trafficUsed.toFixed(1)} / ${trafficLimit} ${t('common.units.gb', 'ГБ')}`}
|
||||
<button onClick={handleClick} className="w-full p-4 text-left">
|
||||
{/* Header: tariff name + status badge + chevron */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-base font-semibold" style={{ color: g.text }}>
|
||||
{subscription.tariff_name || t('subscription.defaultName', 'Подписка')}
|
||||
</span>
|
||||
<StatusBadge status={subscription.status} isTrial={isTrial} t={t} />
|
||||
</div>
|
||||
{!isUnlimited && (
|
||||
<div className="h-1.5 overflow-hidden rounded-full" style={{ background: g.innerBg }}>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${trafficColor}`}
|
||||
style={{ width: `${Math.max(1, trafficPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ChevronRightIcon className="h-4 w-4 shrink-0 opacity-30" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
<div
|
||||
className="mt-2.5 flex items-center gap-4 text-[12px]"
|
||||
style={{ color: g.textSecondary }}
|
||||
>
|
||||
<span className="flex items-center gap-1">
|
||||
<DevicesIcon className="h-3.5 w-3.5 opacity-50" />
|
||||
{subscription.device_limit}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarIcon className="h-3.5 w-3.5 opacity-50" />
|
||||
{formatDate(subscription.end_date, i18n.language)}
|
||||
</span>
|
||||
{!isTrial &&
|
||||
(() => {
|
||||
const isDaily = subscription.is_daily;
|
||||
const enabled = isDaily ? !subscription.is_daily_paused : subscription.autopay_enabled;
|
||||
const label = isDaily
|
||||
? t('subscription.dailyAutoCharge', 'Автосписание')
|
||||
: t('subscription.autopay', 'Автопродление');
|
||||
return (
|
||||
<span
|
||||
className={`flex items-center gap-1 ${enabled ? 'text-success-400/70' : 'text-error-400/50'}`}
|
||||
>
|
||||
{enabled ? (
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
) : (
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{label}
|
||||
{/* Traffic mini progress bar */}
|
||||
{isActive && (
|
||||
<div className="mt-3">
|
||||
<div className="mb-1 flex items-baseline justify-between">
|
||||
<span className="text-[11px] font-medium" style={{ color: g.textSecondary }}>
|
||||
{t('subscription.traffic', 'Трафик')}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</button>
|
||||
<span className="text-[11px] tabular-nums" style={{ color: g.textSecondary }}>
|
||||
{isUnlimited
|
||||
? '∞'
|
||||
: `${trafficUsed.toFixed(1)} / ${trafficLimit} ${t('common.units.gb', 'ГБ')}`}
|
||||
</span>
|
||||
</div>
|
||||
{!isUnlimited && (
|
||||
<div className="h-1.5 overflow-hidden rounded-full" style={{ background: g.innerBg }}>
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${trafficColor}`}
|
||||
style={{ width: `${Math.max(1, trafficPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
<div
|
||||
className="mt-2.5 flex items-center gap-4 text-[12px]"
|
||||
style={{ color: g.textSecondary }}
|
||||
>
|
||||
{footer.kind === 'hidden' && (
|
||||
<span className="flex items-center gap-1">
|
||||
<DevicesIcon className="h-3.5 w-3.5 opacity-50" />
|
||||
{subscription.device_limit}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<CalendarIcon className="h-3.5 w-3.5 opacity-50" />
|
||||
{formatDate(subscription.end_date, i18n.language)}
|
||||
</span>
|
||||
{!isTrial &&
|
||||
(() => {
|
||||
const isDaily = subscription.is_daily;
|
||||
const enabled = isDaily
|
||||
? !subscription.is_daily_paused
|
||||
: subscription.autopay_enabled;
|
||||
const label = isDaily
|
||||
? t('subscription.dailyAutoCharge', 'Автосписание')
|
||||
: t('subscription.autopay', 'Автопродление');
|
||||
return (
|
||||
<span
|
||||
className={`flex items-center gap-1 ${enabled ? 'text-success-400/70' : 'text-error-400/50'}`}
|
||||
>
|
||||
{enabled ? (
|
||||
<CheckIcon className="h-3 w-3" />
|
||||
) : (
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<SubscriptionConnectFooter
|
||||
state={footer}
|
||||
borderColor={borderColor}
|
||||
mutedColor={g.textSecondary}
|
||||
onConnect={() => connect?.onConnect()}
|
||||
onManage={() => connect?.onManage()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
70
src/components/subscription/connectFooterState.test.ts
Normal file
70
src/components/subscription/connectFooterState.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { connectFooterState } from './connectFooterState';
|
||||
|
||||
const base = {
|
||||
status: 'active',
|
||||
subscriptionUrl: 'https://example.invalid/sub',
|
||||
deviceLimit: 5,
|
||||
connected: 2,
|
||||
};
|
||||
|
||||
describe('connectFooterState', () => {
|
||||
it('обычная подписка со свободными слотами зовёт подключить', () => {
|
||||
expect(connectFooterState(base)).toEqual({
|
||||
kind: 'connect',
|
||||
used: 2,
|
||||
limit: 5,
|
||||
unlimited: false,
|
||||
highlight: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('подсвечивает подписку без единого устройства — только там действие и нужно', () => {
|
||||
const state = connectFooterState({ ...base, connected: 0 });
|
||||
expect(state).toMatchObject({ kind: 'connect', highlight: true });
|
||||
});
|
||||
|
||||
it('исчерпанный лимит — это не «нельзя», а повод управлять устройствами', () => {
|
||||
expect(connectFooterState({ ...base, connected: 5 })).toEqual({
|
||||
kind: 'full',
|
||||
used: 5,
|
||||
limit: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('устройств больше лимита (лимит понизили) — тоже полный', () => {
|
||||
expect(connectFooterState({ ...base, connected: 7 })).toMatchObject({ kind: 'full' });
|
||||
});
|
||||
|
||||
it('нулевой лимит означает безлимит, а не запрет', () => {
|
||||
expect(connectFooterState({ ...base, deviceLimit: 0, connected: 12 })).toEqual({
|
||||
kind: 'connect',
|
||||
used: 12,
|
||||
limit: 0,
|
||||
unlimited: true,
|
||||
highlight: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('пока счётчик не пришёл — загрузка, а не догадка о состоянии', () => {
|
||||
expect(connectFooterState({ ...base, connected: undefined })).toEqual({ kind: 'loading' });
|
||||
});
|
||||
|
||||
it.each(['expired', 'disabled'])('у подписки со статусом %s подключать нечего', (status) => {
|
||||
expect(connectFooterState({ ...base, status })).toEqual({ kind: 'hidden' });
|
||||
});
|
||||
|
||||
it.each(['active', 'trial', 'limited'])('статус %s подвал показывает', (status) => {
|
||||
expect(connectFooterState({ ...base, status }).kind).toBe('connect');
|
||||
});
|
||||
|
||||
it.each([null, undefined, ''])('без ссылки на подписку (%s) подвала нет', (subscriptionUrl) => {
|
||||
expect(connectFooterState({ ...base, subscriptionUrl })).toEqual({ kind: 'hidden' });
|
||||
});
|
||||
|
||||
it('отсутствие ссылки важнее незагруженного счётчика', () => {
|
||||
expect(connectFooterState({ ...base, subscriptionUrl: null, connected: undefined })).toEqual({
|
||||
kind: 'hidden',
|
||||
});
|
||||
});
|
||||
});
|
||||
61
src/components/subscription/connectFooterState.ts
Normal file
61
src/components/subscription/connectFooterState.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Состояние подвала карточки подписки — «подключить устройство».
|
||||
*
|
||||
* Вынесено из компонента отдельно, потому что правил тут больше, чем кажется:
|
||||
* безлимит и исчерпанный лимит выражаются одним и тем же полем `device_limit`,
|
||||
* а незагруженный счётчик нельзя путать с нулём подключённых устройств.
|
||||
*/
|
||||
|
||||
export type ConnectFooterState =
|
||||
/** Подключать нечего: подписка истекла или у неё нет ссылки. */
|
||||
| { kind: 'hidden' }
|
||||
/** Счётчик устройств ещё не пришёл — показываем скелетон, а не догадку. */
|
||||
| { kind: 'loading' }
|
||||
/** Есть куда подключаться. `highlight` — ни одного устройства, зовём заметнее. */
|
||||
| { kind: 'connect'; used: number; limit: number; unlimited: boolean; highlight: boolean }
|
||||
/** Слоты кончились — ведём разбираться, а не блокируем. */
|
||||
| { kind: 'full'; used: number; limit: number };
|
||||
|
||||
/** Статусы, при которых доступ ещё работает и устройство есть смысл подключать. */
|
||||
const CONNECTABLE_STATUSES = new Set(['active', 'trial', 'limited']);
|
||||
|
||||
export interface ConnectFooterInput {
|
||||
status: string;
|
||||
/** Ссылка на подписку из панели: без неё подключать не к чему. */
|
||||
subscriptionUrl: string | null | undefined;
|
||||
/** 0 означает «без лимита устройств», а не «нельзя ни одного». */
|
||||
deviceLimit: number;
|
||||
/** `undefined`, пока запрос числа устройств не завершился. */
|
||||
connected: number | undefined;
|
||||
}
|
||||
|
||||
export function connectFooterState({
|
||||
status,
|
||||
subscriptionUrl,
|
||||
deviceLimit,
|
||||
connected,
|
||||
}: ConnectFooterInput): ConnectFooterState {
|
||||
if (!subscriptionUrl || !CONNECTABLE_STATUSES.has(status)) {
|
||||
return { kind: 'hidden' };
|
||||
}
|
||||
|
||||
if (connected === undefined) {
|
||||
return { kind: 'loading' };
|
||||
}
|
||||
|
||||
const unlimited = deviceLimit === 0;
|
||||
|
||||
// `>=`, а не `===`: лимит можно понизить ниже числа уже подключённых
|
||||
// устройств, и тогда слотов «минус один» — состояние всё равно полное.
|
||||
if (!unlimited && connected >= deviceLimit) {
|
||||
return { kind: 'full', used: connected, limit: deviceLimit };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'connect',
|
||||
used: connected,
|
||||
limit: deviceLimit,
|
||||
unlimited,
|
||||
highlight: connected === 0,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useCurrency } from '../../../hooks/useCurrency';
|
||||
import { usePromoDiscount } from '../../../hooks/usePromoDiscount';
|
||||
import { usePlatform } from '../../../platform';
|
||||
import { openPaymentUrl } from '../../../utils/openPaymentUrl';
|
||||
import { getMonthlyPriceKopeks } from '../../../utils/pricing';
|
||||
import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
|
||||
import type { Tariff, TariffPeriod } from '../../../types';
|
||||
|
||||
@@ -35,6 +36,8 @@ export interface TariffPurchaseFormProps {
|
||||
balanceKopeks: number | undefined;
|
||||
/** СБП-оформление (Platega recurrent) доступно — показать вторую CTA. */
|
||||
sbpPurchaseEnabled?: boolean;
|
||||
/** Оформление привязкой Lava доступно — показать вторую CTA. */
|
||||
lavaPurchaseEnabled?: boolean;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
@@ -43,6 +46,7 @@ export function TariffPurchaseForm({
|
||||
subscriptionId,
|
||||
balanceKopeks,
|
||||
sbpPurchaseEnabled = false,
|
||||
lavaPurchaseEnabled = false,
|
||||
onBack,
|
||||
}: TariffPurchaseFormProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -142,6 +146,47 @@ export function TariffPurchaseForm({
|
||||
</>
|
||||
);
|
||||
|
||||
const lavaPurchaseMutation = useMutation({
|
||||
mutationFn: () => subscriptionApi.purchaseWithLavaRecurring(tariff.id),
|
||||
onSuccess: (data) => {
|
||||
if (data.redirect_url) {
|
||||
openPaymentUrl(data.redirect_url, platform, openLink);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['lava-recurring', data.subscription_id] });
|
||||
navigate('/subscriptions', { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const lavaPurchaseButton = lavaPurchaseEnabled && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => lavaPurchaseMutation.mutate()}
|
||||
disabled={lavaPurchaseMutation.isPending || purchaseMutation.isPending}
|
||||
className="mt-2 w-full rounded-xl border border-accent-500/40 bg-accent-500/10 py-3 text-sm font-medium text-accent-400 transition-colors hover:bg-accent-500/20 disabled:opacity-50"
|
||||
>
|
||||
{lavaPurchaseMutation.isPending ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{t('common.loading')}
|
||||
</span>
|
||||
) : (
|
||||
t('subscription.lavaRecurring.purchaseButton')
|
||||
)}
|
||||
</button>
|
||||
<div className="mt-1.5 text-center text-[11px] text-dark-500">
|
||||
{t('subscription.lavaRecurring.purchaseHint')}
|
||||
</div>
|
||||
{lavaPurchaseMutation.isError && (
|
||||
<div className="mt-2 text-center text-sm text-error-400">
|
||||
{getErrorMessage(lavaPurchaseMutation.error)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Smooth scroll the form into view when first mounted.
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
@@ -240,6 +285,7 @@ export function TariffPurchaseForm({
|
||||
</button>
|
||||
|
||||
{sbpPurchaseButton}
|
||||
{lavaPurchaseButton}
|
||||
|
||||
{purchaseMutation.isError &&
|
||||
!getInsufficientBalanceError(purchaseMutation.error) && (
|
||||
@@ -279,10 +325,7 @@ export function TariffPurchaseForm({
|
||||
const displayDiscount = promoPeriod.percent;
|
||||
const displayOriginal = promoPeriod.original;
|
||||
const displayPrice = promoPeriod.price;
|
||||
const displayPerMonth =
|
||||
displayPrice !== period.price_kopeks
|
||||
? Math.round(displayPrice / Math.max(1, period.days / 30))
|
||||
: period.price_per_month_kopeks;
|
||||
const displayPerMonth = getMonthlyPriceKopeks(displayPrice, period.days);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -317,9 +360,11 @@ export function TariffPurchaseForm({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-dark-500">
|
||||
{formatPrice(displayPerMonth)}/{t('subscription.month')}
|
||||
</div>
|
||||
{displayPerMonth !== null && (
|
||||
<div className="mt-1 text-xs text-dark-500">
|
||||
{formatPrice(displayPerMonth)}/{t('subscription.month')}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -666,6 +711,7 @@ export function TariffPurchaseForm({
|
||||
</button>
|
||||
|
||||
{sbpPurchaseButton}
|
||||
{lavaPurchaseButton}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
90
src/components/ui/ResponsiveSheet.tsx
Normal file
90
src/components/ui/ResponsiveSheet.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { XCloseIcon } from '@/components/icons';
|
||||
import { useFocusTrap } from '@/hooks/useFocusTrap';
|
||||
import { Sheet } from './Sheet';
|
||||
|
||||
/** Ширина, с которой нижний шит перестаёт быть уместным. */
|
||||
const DESKTOP_QUERY = '(min-width: 640px)';
|
||||
|
||||
function useIsDesktop(): boolean {
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => typeof window !== 'undefined' && window.matchMedia(DESKTOP_QUERY).matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia(DESKTOP_QUERY);
|
||||
const onChange = (event: MediaQueryListEvent) => setIsDesktop(event.matches);
|
||||
query.addEventListener('change', onChange);
|
||||
setIsDesktop(query.matches);
|
||||
return () => query.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return isDesktop;
|
||||
}
|
||||
|
||||
interface ResponsiveSheetProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Нижний шит на телефоне, обычное окно на десктопе.
|
||||
*
|
||||
* Шит — мобильная идиома: ручка перетаскивания мышью бессмысленна, а
|
||||
* прижатая к нижнему краю плашка на широком экране оставляет центр пустым
|
||||
* и обрезает нижние скругления. На десктопе то же содержимое показывается
|
||||
* центрированным окном с ловушкой фокуса и закрытием по Escape.
|
||||
*/
|
||||
export function ResponsiveSheet({ isOpen, onClose, title, children }: ResponsiveSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const isDesktop = useIsDesktop();
|
||||
// Ловушка нужна только своей ветке: у Sheet она уже своя.
|
||||
const dialogRef = useFocusTrap<HTMLDivElement>(isOpen && isDesktop, { onEscape: onClose });
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
if (!isDesktop) {
|
||||
return (
|
||||
<Sheet isOpen onClose={onClose} title={title}>
|
||||
{children}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-dark-950/70 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
className="relative flex max-h-[calc(100dvh-2rem)] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-dark-700 bg-dark-900 shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 px-5 pb-3 pt-5">
|
||||
<h3 className="text-lg font-semibold text-dark-50">{title}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close', 'Закрыть')}
|
||||
className="-mr-1.5 flex min-h-[44px] min-w-[44px] shrink-0 items-center justify-center rounded-lg text-dark-400 transition-colors hover:bg-dark-800 hover:text-dark-200"
|
||||
>
|
||||
<XCloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
enableVerticalSwipes as sdkEnableVerticalSwipes,
|
||||
expandViewport,
|
||||
retrieveLaunchParams,
|
||||
retrieveRawInitData,
|
||||
themeParamsState,
|
||||
closeMiniApp as sdkCloseMiniApp,
|
||||
postEvent,
|
||||
} from '@telegram-apps/sdk-react';
|
||||
import { getTelegramInitData as readTelegramInitData } from '../utils/telegramInitData';
|
||||
|
||||
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
|
||||
|
||||
@@ -90,11 +90,7 @@ export function isTelegramMobile(): boolean {
|
||||
}
|
||||
|
||||
export function getTelegramInitData(): string | null {
|
||||
try {
|
||||
return retrieveRawInitData() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return readTelegramInitData();
|
||||
}
|
||||
|
||||
function isDarkHexColor(hex: string): boolean {
|
||||
|
||||
@@ -207,6 +207,9 @@
|
||||
"orOpenInApp": "Or open the bot in the app",
|
||||
"loginFailed": "Login Failed",
|
||||
"telegramWidgetBlocked": "Telegram login widget is unavailable. Use the bot to sign in:",
|
||||
"deepLinkIntro": "Confirm sign-in right in the bot — no phone number needed:",
|
||||
"loginWithBot": "Login via bot",
|
||||
"backToWidget": "Back to widget login",
|
||||
"openBotToLogin": "Open bot to sign in",
|
||||
"waitingForConfirmation": "Waiting for confirmation...",
|
||||
"deepLinkExpired": "Link expired. Please try again.",
|
||||
@@ -225,6 +228,10 @@
|
||||
"invalidCredentials": "Invalid email or password",
|
||||
"tooManyAttempts": "Too many attempts. Please try again later",
|
||||
"verificationEmailNotice": "After registration, a verification email will be sent to your address",
|
||||
"legalConsentPrefix": "I have read the",
|
||||
"legalConsentTitle": "One more step",
|
||||
"legalConsentSubtitle": "To create an account, confirm that you have read the documents.",
|
||||
"legalConsentContinue": "Continue",
|
||||
"checkEmail": "Check your email",
|
||||
"verificationSent": "We sent a verification link to:",
|
||||
"clickLinkToVerify": "Click the link in the email to verify your account and log in.",
|
||||
@@ -701,10 +708,47 @@
|
||||
"purchaseButton": "⚡ Subscribe with SBP auto-payment",
|
||||
"purchaseHint": "First charge confirms the binding in your bank app; renewals are charged automatically per the tariff cadence."
|
||||
},
|
||||
"lavaRecurring": {
|
||||
"title": "Lava auto-renewal",
|
||||
"connect": "Connect",
|
||||
"payFirst": "Pay the first invoice",
|
||||
"cancel": "Disable",
|
||||
"confirmCancel": "Disable Lava auto-renewal? The subscription will no longer renew automatically.",
|
||||
"cancelled": "Lava auto-renewal disabled",
|
||||
"enableError": "Could not enable Lava auto-renewal",
|
||||
"cancelError": "Could not disable Lava auto-renewal",
|
||||
"statusPending": "Waiting for the first invoice to be paid",
|
||||
"statusActive": "Active",
|
||||
"statusPastDue": "Payment failed — please try again",
|
||||
"nextCharge": "next charge on {{date}}",
|
||||
"amountPerPeriod": "{{amount}} ₽ {{period}}",
|
||||
"amountPerDays": "{{amount}} ₽ every {{days}} days",
|
||||
"period": {
|
||||
"day": "daily",
|
||||
"week": "weekly",
|
||||
"month": "monthly",
|
||||
"quarter": "quarterly",
|
||||
"halfYear": "every six months",
|
||||
"year": "yearly"
|
||||
},
|
||||
"autopayHint": "The subscription will renew automatically via Lava",
|
||||
"purchaseButton": "⚡ Subscribe with Lava auto-renewal",
|
||||
"purchaseHint": "The first invoice is paid via a Lava link; renewals are then charged automatically on the product’s schedule."
|
||||
},
|
||||
"backToList": "Back to subscriptions",
|
||||
"confirmDelete": "Delete subscription?",
|
||||
"dailyAutoCharge": "Daily auto-charge",
|
||||
"defaultName": "Subscription",
|
||||
"connectFooter": {
|
||||
"connect": "Connect a device",
|
||||
"full": "All slots are in use",
|
||||
"connectedDevices": "Connected devices",
|
||||
"disconnect": "Disconnect",
|
||||
"addSlots": "Add slots",
|
||||
"limitExplained": "The «{{name}}» plan gives {{count}} slots and all are in use. Free one up or add more.",
|
||||
"limitExplainedNoTopup": "The «{{name}}» plan gives {{count}} slots and all are in use. Free up a slot to continue.",
|
||||
"noTopupHint": "Slots cannot be bought on a trial subscription — free one up or switch to a paid plan."
|
||||
},
|
||||
"delete": "Delete",
|
||||
"deleteTitle": "Delete subscription",
|
||||
"get_config": "Get config",
|
||||
@@ -1100,7 +1144,12 @@
|
||||
"contactSupport": "Please contact {{username}} for support",
|
||||
"contactUs": "Contact Support",
|
||||
"create_ticket": "Create ticket",
|
||||
"writeButton": "Message"
|
||||
"writeButton": "Message",
|
||||
"errors": {
|
||||
"alreadyOpenTicket": "You already have an open ticket. Please continue the conversation there — a new ticket can be created once the current one is closed.",
|
||||
"createFailed": "Could not create the ticket. Please try again.",
|
||||
"replyFailed": "Could not send the reply. Please try again."
|
||||
}
|
||||
},
|
||||
"wheel": {
|
||||
"title": "Fortune Wheel",
|
||||
@@ -1802,6 +1851,47 @@
|
||||
},
|
||||
"usersOnlineCount": "{{count}} online"
|
||||
},
|
||||
"geoCheck": {
|
||||
"title": "GeoCheck",
|
||||
"start": "Run check",
|
||||
"running": "Running the geo check",
|
||||
"runningHint": "The node is testing its connection — this usually takes up to a minute.",
|
||||
"changeRoute": "Change route",
|
||||
"rerun": "Run again",
|
||||
"reportAlt": "GeoCheck report for {{node}}",
|
||||
"invalidIp": "Enter a valid IPv4 or IPv6 address",
|
||||
"invalidInterface": "Enter a valid interface name",
|
||||
"fullscreen": "Fullscreen",
|
||||
"exitFullscreen": "Exit fullscreen",
|
||||
"copyJson": "Copy JSON report",
|
||||
"download": "Download SVG",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"zoomReset": "Reset zoom",
|
||||
"mode": {
|
||||
"legend": "Check route",
|
||||
"default": "Default",
|
||||
"ip": "IP address",
|
||||
"interface": "Interface"
|
||||
},
|
||||
"hint": {
|
||||
"default": "The node will use its default outbound route.",
|
||||
"ip": "Pick one of the node addresses or type any address.",
|
||||
"interface": "Pick one of the node interfaces or type any name."
|
||||
},
|
||||
"placeholder": {
|
||||
"default": "Automatic"
|
||||
},
|
||||
"tab": {
|
||||
"report": "Report",
|
||||
"json": "JSON"
|
||||
},
|
||||
"error": {
|
||||
"title": "Check failed",
|
||||
"timeout": "The node did not answer in time. Try running the check again.",
|
||||
"generic": "The check could not be completed."
|
||||
}
|
||||
},
|
||||
"squads": {
|
||||
"synced": "Synced",
|
||||
"notSynced": "Not synced",
|
||||
@@ -1930,7 +2020,7 @@
|
||||
},
|
||||
"broadcasts": {
|
||||
"title": "Broadcasts",
|
||||
"subtitle": "History and management",
|
||||
"subtitle": "Mass sending of messages to users",
|
||||
"create": "Create Broadcast",
|
||||
"empty": "No broadcasts",
|
||||
"selectFilter": "Select audience",
|
||||
@@ -2275,9 +2365,16 @@
|
||||
"sys_maintenance": "Maintenance",
|
||||
"sys_backup": "Backup",
|
||||
"sys_version": "Version",
|
||||
"sys_logging": "Logging"
|
||||
"sys_logging": "Logging",
|
||||
"iface_info": "Legal documents",
|
||||
"iface_menu": "Menu button",
|
||||
"sys_cabinet": "Cabinet",
|
||||
"sys_other": "Other settings"
|
||||
},
|
||||
"categories": {
|
||||
"CABINET": "Cabinet",
|
||||
"INFO_PAGES": "Legal documents and FAQ",
|
||||
"MENU": "Menu button",
|
||||
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
||||
},
|
||||
@@ -2293,7 +2390,15 @@
|
||||
"offlineConv": "Offline Conversions",
|
||||
"apiKey": "API Key",
|
||||
"legalFooter": "Legal Footer",
|
||||
"legalFooterDesc": "Links to the offer, privacy policy and recurring payments at the bottom of the login page"
|
||||
"legalFooterDesc": "Links to the offer, privacy policy and recurring payments at the bottom of the login page",
|
||||
"botStartVideo": "Bot start-menu video",
|
||||
"botStartVideoDesc": "The bot attaches this video to the /start message instead of the logo image. The file is uploaded to Telegram once — we only keep its identifier.",
|
||||
"botStartVideoUpload": "Upload video",
|
||||
"botStartVideoReplace": "Replace video",
|
||||
"botStartVideoRemove": "Remove",
|
||||
"botStartVideoActive": "Video is set",
|
||||
"botStartVideoNone": "No video — the menu uses the logo image",
|
||||
"botStartVideoError": "Could not upload the video"
|
||||
},
|
||||
"bulkActions": {
|
||||
"title": "Bulk Actions",
|
||||
@@ -2740,7 +2845,9 @@
|
||||
"periodsRequired": "Add at least one period",
|
||||
"dailyPriceRequired": "Enter a price per day greater than 0",
|
||||
"trafficPackagesRequired": "Add at least one traffic package (or disable traffic topup)"
|
||||
}
|
||||
},
|
||||
"lavaProductLabel": "Lava product (auto-renewal)",
|
||||
"lavaProductDesc": "Product UUID from the Lava dashboard: price and charge period are configured there. Empty — auto-renewal is unavailable for this tariff."
|
||||
},
|
||||
"servers": {
|
||||
"title": "Squad Management",
|
||||
@@ -3131,7 +3238,9 @@
|
||||
"validDaysHint": "0 or empty — perpetual",
|
||||
"create": "Create batch",
|
||||
"creating": "Creating…",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"maxPerUser": "Per-user limit",
|
||||
"maxPerUserHint": "How many coupons from this batch one person may redeem. 0 — unlimited; use 1 for giveaways and contests."
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Enter the batch name",
|
||||
@@ -3156,7 +3265,8 @@
|
||||
"validUntil": "Valid until",
|
||||
"perpetual": "Perpetual",
|
||||
"createdAt": "Created",
|
||||
"linksTitle": "Active links ({{count}})"
|
||||
"linksTitle": "Active links ({{count}})",
|
||||
"maxPerUser": "Per user"
|
||||
},
|
||||
"revoke": {
|
||||
"button": "Revoke unredeemed ({{count}})",
|
||||
@@ -3169,7 +3279,16 @@
|
||||
"errors": {
|
||||
"loadFailed": "Batch not found",
|
||||
"createFailed": "Failed to create the batch",
|
||||
"revokeFailed": "Failed to revoke the batch"
|
||||
"revokeFailed": "Failed to revoke the batch",
|
||||
"deleteFailed": "Could not delete the batch"
|
||||
},
|
||||
"delete": {
|
||||
"button": "🗑 Delete batch",
|
||||
"confirmTitle": "Delete the batch?",
|
||||
"confirmText": "{{count}} coupons will be deleted along with the batch itself.",
|
||||
"redeemedWarning": "{{count}} of them are already redeemed. The redemption history will be lost; granted subscriptions are not revoked.",
|
||||
"confirm": "Delete",
|
||||
"success": "Batch deleted ({{count}} coupons)"
|
||||
}
|
||||
},
|
||||
"promocodes": {
|
||||
@@ -3240,7 +3359,10 @@
|
||||
"save": "Save",
|
||||
"defaultTrialTariff": "Default trial tariff",
|
||||
"selectTariff": "Select tariff",
|
||||
"tariff": "Tariff"
|
||||
"tariff": "Tariff",
|
||||
"includeTraffic": "Traffic",
|
||||
"trafficAmount": "Traffic amount",
|
||||
"gb": "GB"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Promo code statistics",
|
||||
@@ -3314,7 +3436,8 @@
|
||||
"daysRequired": "Number of days must be greater than 0",
|
||||
"groupRequired": "Select a discount group",
|
||||
"discountPercentInvalid": "Discount percent must be between 1 and 100",
|
||||
"discountHoursRequired": "Specify the discount validity period in hours"
|
||||
"discountHoursRequired": "Specify the discount validity period in hours",
|
||||
"trafficRequired": "Traffic amount must be greater than 0"
|
||||
}
|
||||
},
|
||||
"promoGroups": {
|
||||
@@ -3396,7 +3519,8 @@
|
||||
"byDate": "By date",
|
||||
"byBalance": "By balance",
|
||||
"byActivity": "By activity",
|
||||
"bySpent": "By spending"
|
||||
"bySpent": "By spending",
|
||||
"byExpiry": "By expiry"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "Showing {{from}}-{{to}} of {{total}}"
|
||||
@@ -3586,7 +3710,11 @@
|
||||
"sbpCancelled": "SBP auto-payment disabled",
|
||||
"sbpStatus_PENDING": "Pending",
|
||||
"sbpStatus_ACTIVE": "Active",
|
||||
"sbpStatus_PAST_DUE": "Payment failed"
|
||||
"sbpStatus_PAST_DUE": "Payment failed",
|
||||
"deleteTitle": "Delete subscription",
|
||||
"deleteHint": "The subscription and its devices will be removed permanently",
|
||||
"deleteButton": "Delete subscription",
|
||||
"deleted": "Subscription deleted"
|
||||
},
|
||||
"balance": {
|
||||
"current": "Current balance",
|
||||
@@ -3807,7 +3935,9 @@
|
||||
"sendError": "Failed to send offer",
|
||||
"notificationsSent": "Notifications sent",
|
||||
"offersCreated": "Offers created",
|
||||
"notificationsFailed": "(failed: {{count}})"
|
||||
"notificationsFailed": "(failed: {{count}})",
|
||||
"deliveryTitle": "Telegram delivery",
|
||||
"openAsBroadcast": "Open as broadcast"
|
||||
},
|
||||
"notFound": "Template not found",
|
||||
"noActiveTemplates": "No active templates",
|
||||
@@ -5453,7 +5583,15 @@
|
||||
"shareModalActivateVia": "Activate via bot:",
|
||||
"shareModalActivateViaCabinet": "Or via website:",
|
||||
"copyMessage": "Copy message",
|
||||
"shareToastCopied": "Message copied"
|
||||
"shareToastCopied": "Message copied",
|
||||
"qrHint": "Scan to activate the gift",
|
||||
"scanButton": "📷 Scan QR",
|
||||
"scanInProgress": "Scanning…",
|
||||
"scanCancel": "Stop scanning",
|
||||
"scanDescription": "Point the camera at the gift QR code",
|
||||
"scanNotRecognized": "No gift code in this QR",
|
||||
"scanError": "Could not scan",
|
||||
"scanNoCamera": "Camera unavailable — enter the code manually"
|
||||
},
|
||||
"news": {
|
||||
"title": "News & Updates",
|
||||
|
||||
@@ -199,6 +199,9 @@
|
||||
"orOpenInApp": "یا ربات را در برنامه باز کنید",
|
||||
"loginFailed": "ورود ناموفق",
|
||||
"telegramWidgetBlocked": "ویجت ورود تلگرام در دسترس نیست. از طریق ربات وارد شوید:",
|
||||
"deepLinkIntro": "ورود را مستقیماً در ربات تأیید کنید — بدون نیاز به شماره تلفن:",
|
||||
"loginWithBot": "ورود از طریق ربات",
|
||||
"backToWidget": "بازگشت به ورود با ویجت",
|
||||
"openBotToLogin": "باز کردن ربات برای ورود",
|
||||
"waitingForConfirmation": "در انتظار تایید...",
|
||||
"deepLinkExpired": "لینک منقضی شده است. لطفا دوباره تلاش کنید.",
|
||||
@@ -228,6 +231,10 @@
|
||||
"sendResetLink": "ارسال لینک بازنشانی",
|
||||
"tooManyAttempts": "تلاشهای زیاد. لطفاً بعداً امتحان کنید",
|
||||
"verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد",
|
||||
"legalConsentPrefix": "من این سند را مطالعه کردهام:",
|
||||
"legalConsentTitle": "یک قدم دیگر",
|
||||
"legalConsentSubtitle": "برای ساخت حساب، تأیید کنید که اسناد را مطالعه کردهاید.",
|
||||
"legalConsentContinue": "ادامه",
|
||||
"continueWithGoogle": "ادامه با Google",
|
||||
"continueWithYandex": "ادامه با Yandex",
|
||||
"continueWithDiscord": "ادامه با Discord",
|
||||
@@ -276,7 +283,7 @@
|
||||
"traffic": "ترافیک",
|
||||
"devices": "دستگاهها",
|
||||
"expiredDate": "منقضی شده",
|
||||
"activeUntil": "منقضی شده — تا {{date}}"
|
||||
"activeUntil": "فعال تا"
|
||||
},
|
||||
"deviceLimitReached": "برای افزودن دستگاه جدید، دستگاههای فعلی را قطع کنید",
|
||||
"suspended": {
|
||||
@@ -287,9 +294,9 @@
|
||||
"showAll": "نمایش همه",
|
||||
"subscriptions": "اشتراکها",
|
||||
"connectDevice": "اتصال دستگاه",
|
||||
"devicesConnectedUnlimited": "{{count}} دستگاه متصل",
|
||||
"devicesConnectedUnlimited": "{{used}} دستگاه متصل · نامحدود",
|
||||
"devicesOfMax": "{{used}} از {{max}}",
|
||||
"maxUsage": "حداکثر",
|
||||
"maxUsage": "حداکثر {{amount}}",
|
||||
"remaining": "باقیمانده",
|
||||
"stats": {
|
||||
"balance": "موجودی",
|
||||
@@ -298,9 +305,9 @@
|
||||
"tariff": "تعرفه",
|
||||
"trafficUsageTitle": "مصرف ترافیک",
|
||||
"trialOffer": {
|
||||
"freeDesc": "{{days}} روز رایگان آزمایش کنید — بدون پرداخت",
|
||||
"freeDesc": "ویپیان ما را رایگان امتحان کنید: بدون هیچ تعهدی",
|
||||
"freeTitle": "دوره آزمایشی رایگان",
|
||||
"paidDesc": "{{days}} روز آزمایشی فقط با {{price}}",
|
||||
"paidDesc": "با یک اشتراک آزمایشی شروع کنید",
|
||||
"paidTitle": "دوره آزمایشی پرداختی"
|
||||
},
|
||||
"unlimited": "نامحدود",
|
||||
@@ -458,7 +465,7 @@
|
||||
"newDeviceLimit": "محدودیت جدید دستگاه",
|
||||
"reduce": "کاهش",
|
||||
"reduceDevices": "کاهش تعداد دستگاهها",
|
||||
"reduceDevicesDescription": "محدودیت دستگاههای خود را تنظیم کنید. در صورت لزوم ابتدا دستگاههای اضافه را قطع کنید",
|
||||
"reduceDevicesDescription": "میتوانید تعداد دستگاهها را تا حداقل مجاز در تعرفه کاهش دهید",
|
||||
"reduceDevicesTitle": "کاهش تعداد دستگاهها",
|
||||
"reduceUnavailable": "در حال حاضر کاهش تعداد دستگاهها امکانپذیر نیست",
|
||||
"reducing": "در حال کاهش…"
|
||||
@@ -600,16 +607,53 @@
|
||||
"purchaseButton": "⚡ اشتراک با پرداخت خودکار SBP",
|
||||
"purchaseHint": "اولین برداشت، اتصال را در اپ بانک تأیید میکند؛ تمدیدها بهصورت خودکار برداشت میشوند."
|
||||
},
|
||||
"lavaRecurring": {
|
||||
"title": "تمدید خودکار Lava",
|
||||
"connect": "اتصال",
|
||||
"payFirst": "پرداخت صورتحساب اول",
|
||||
"cancel": "غیرفعالسازی",
|
||||
"confirmCancel": "تمدید خودکار Lava غیرفعال شود؟ اشتراک دیگر بهطور خودکار تمدید نمیشود.",
|
||||
"cancelled": "تمدید خودکار Lava غیرفعال شد",
|
||||
"enableError": "فعالسازی تمدید خودکار Lava ناموفق بود",
|
||||
"cancelError": "غیرفعالسازی تمدید خودکار Lava ناموفق بود",
|
||||
"statusPending": "در انتظار پرداخت صورتحساب اول",
|
||||
"statusActive": "فعال",
|
||||
"statusPastDue": "پرداخت ناموفق بود — دوباره تلاش کنید",
|
||||
"nextCharge": "برداشت بعدی {{date}}",
|
||||
"amountPerPeriod": "{{amount}} ₽ {{period}}",
|
||||
"amountPerDays": "هر {{days}} روز {{amount}} ₽",
|
||||
"period": {
|
||||
"day": "روزانه",
|
||||
"week": "هفتگی",
|
||||
"month": "ماهانه",
|
||||
"quarter": "هر سه ماه",
|
||||
"halfYear": "هر شش ماه",
|
||||
"year": "سالانه"
|
||||
},
|
||||
"autopayHint": "اشتراک بهطور خودکار از طریق Lava تمدید میشود",
|
||||
"purchaseButton": "⚡ ثبتنام با تمدید خودکار Lava",
|
||||
"purchaseHint": "صورتحساب اول از طریق لینک Lava پرداخت میشود؛ سپس تمدیدها بر اساس دوره محصول بهطور خودکار کسر میشوند."
|
||||
},
|
||||
"backToList": "بازگشت به فهرست اشتراکها",
|
||||
"confirmDelete": "اشتراک حذف شود؟",
|
||||
"cta": {
|
||||
"renewHint": "اشتراک منقضی شده — برای ادامه استفاده تمدید کنید",
|
||||
"activeHint": "اشتراک فعال است — میتوانید دستگاهها را متصل کرده و از VPN استفاده کنید",
|
||||
"expiredHint": "اشتراک منقضی شده — برای ادامه استفاده تمدید کنید",
|
||||
"trialHint": "شما در دوره آزمایشی هستید — برای دسترسی به همه امکانات به تعرفه پرداختی ارتقا دهید"
|
||||
"activeHint": "تمدید و تغییر تعرفه",
|
||||
"expiredHint": "یک تعرفه انتخاب و پرداخت کنید",
|
||||
"trialHint": "ترافیک و دستگاه بیشتر"
|
||||
},
|
||||
"dailyAutoCharge": "برداشت خودکار روزانه",
|
||||
"defaultName": "اشتراک",
|
||||
"connectFooter": {
|
||||
"connect": "اتصال دستگاه",
|
||||
"full": "همه ظرفیتها پر است",
|
||||
"connectedDevices": "دستگاههای متصل",
|
||||
"disconnect": "قطع اتصال",
|
||||
"addSlots": "افزودن ظرفیت",
|
||||
"limitExplained": "طرح «{{name}}» {{count}} ظرفیت دارد و همه پر است. یکی را آزاد کنید یا ظرفیت اضافه کنید.",
|
||||
"limitExplainedNoTopup": "طرح «{{name}}» {{count}} ظرفیت دارد و همه پر است. یک ظرفیت را آزاد کنید.",
|
||||
"noTopupHint": "روی اشتراک آزمایشی نمیتوان ظرفیت خرید — یکی را آزاد کنید یا طرح پولی بگیرید."
|
||||
},
|
||||
"delete": "حذف",
|
||||
"deleteTitle": "حذف اشتراک",
|
||||
"get_config": "دریافت پیکربندی",
|
||||
@@ -626,16 +670,16 @@
|
||||
"statusTrial": "آزمایشی",
|
||||
"daysShort": "روز",
|
||||
"expiredBanner": {
|
||||
"selectTariff": "تعرفه را انتخاب کنید",
|
||||
"selectTariff": "اشتراک شما منقضی شده است. برای بازیابی دسترسی به VPN، یک تعرفه را از پایین انتخاب کنید.",
|
||||
"title": "اشتراک منقضی شده است"
|
||||
},
|
||||
"trialInfo": {
|
||||
"description": "{{days}} روز سرویس ما را رایگان امتحان کنید",
|
||||
"remaining": "{{count}} روز باقیمانده",
|
||||
"description": "شما در حال استفاده از نسخهٔ آزمایشی سرویس هستید. برای دسترسی کامل، یک تعرفه را در پایین انتخاب کنید.",
|
||||
"remaining": "باقیمانده",
|
||||
"title": "دوره آزمایشی"
|
||||
},
|
||||
"trialUpgrade": {
|
||||
"description": "برای دسترسی به همه امکانات و ادامه استفاده به اشتراک پرداختی ارتقا دهید",
|
||||
"description": "دوره آزمایشی شما بهزودی به پایان میرسد. برای ادامه استفاده از VPN بدون محدودیت، یک تعرفه مناسب انتخاب کنید.",
|
||||
"title": "ارتقای اشتراک"
|
||||
}
|
||||
},
|
||||
@@ -973,7 +1017,12 @@
|
||||
"ticketsDisabled": "تیکتها غیرفعال است",
|
||||
"useExternalLink": "لطفاً از لینک خارجی برای دریافت پشتیبانی استفاده کنید",
|
||||
"create_ticket": "ایجاد تیکت",
|
||||
"writeButton": "پیام"
|
||||
"writeButton": "پیام",
|
||||
"errors": {
|
||||
"alreadyOpenTicket": "شما در حال حاضر یک تیکت باز دارید. گفتگو را در همان تیکت ادامه دهید؛ پس از بسته شدن آن میتوانید تیکت جدید ایجاد کنید.",
|
||||
"createFailed": "ایجاد تیکت ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||
"replyFailed": "ارسال پاسخ ناموفق بود. لطفاً دوباره تلاش کنید."
|
||||
}
|
||||
},
|
||||
"wheel": {
|
||||
"title": "چرخ شانس",
|
||||
@@ -1008,7 +1057,7 @@
|
||||
"noSubscription": "برای چرخاندن گردونه نیاز به اشتراک فعال دارید. ابتدا اشتراک خود را فعال کنید.",
|
||||
"selectSubscription": "ابتدا یک اشتراک انتخاب کنید"
|
||||
},
|
||||
"payWithStars": "پرداخت {stars} Stars",
|
||||
"payWithStars": "پرداخت Stars",
|
||||
"processingPayment": "در حال پردازش...",
|
||||
"starsPaymentSuccess": "پرداخت موفق! نتیجه به تلگرام ارسال شد.",
|
||||
"starsPaymentFailed": "پرداخت ناموفق. دوباره تلاش کنید.",
|
||||
@@ -1564,7 +1613,7 @@
|
||||
},
|
||||
"broadcasts": {
|
||||
"title": "نشریات",
|
||||
"subtitle": "تاریخچه و مدیریت",
|
||||
"subtitle": "ارسال گروهی پیام به کاربران",
|
||||
"create": "ایجاد پیام",
|
||||
"empty": "هیچ نشریهای نیست",
|
||||
"selectFilter": "انتخاب مخاطبین",
|
||||
@@ -1652,46 +1701,46 @@
|
||||
"telegramSection": "ارسال انبوه تلگرام"
|
||||
},
|
||||
"pinnedMessages": {
|
||||
"title": "Pinned Messages",
|
||||
"subtitle": "Manage pinned messages for users",
|
||||
"create": "Create",
|
||||
"empty": "No pinned messages",
|
||||
"content": "Message text",
|
||||
"contentPlaceholder": "Enter pinned message text...",
|
||||
"noContent": "No text",
|
||||
"media": "Media file",
|
||||
"addMedia": "Add media",
|
||||
"uploading": "Uploading...",
|
||||
"settings": "Settings",
|
||||
"sendBeforeMenu": "Send before menu",
|
||||
"sendOnEveryStart": "Send on every start",
|
||||
"broadcast": "Broadcast",
|
||||
"broadcastOnCreate": "Broadcast on create",
|
||||
"broadcastConfirm": "Broadcast pinned message to all active users?",
|
||||
"broadcastToAll": "Broadcast to all",
|
||||
"activate": "Activate",
|
||||
"deactivate": "Deactivate",
|
||||
"unpin": "Unpin",
|
||||
"unpinAll": "Unpin for everyone",
|
||||
"unpinConfirm": "Unpin message for all users?",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Delete this pinned message?",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"createSuccess": "Pinned message created",
|
||||
"updateSuccess": "Message updated",
|
||||
"deleteSuccess": "Message deleted",
|
||||
"activateSuccess": "Message activated",
|
||||
"deactivateSuccess": "Message deactivated",
|
||||
"broadcastSuccess": "Broadcast completed",
|
||||
"unpinSuccess": "Messages unpinned",
|
||||
"sentCount": "Sent",
|
||||
"failedCount": "Failed",
|
||||
"unpinnedCount": "Unpinned",
|
||||
"editMessage": "Edit",
|
||||
"cantDeleteActive": "Cannot delete active message"
|
||||
"title": "پیامهای سنجاقشده",
|
||||
"subtitle": "مدیریت پیامهای سنجاقشده برای کاربران",
|
||||
"create": "ایجاد",
|
||||
"empty": "هیچ پیام سنجاقشدهای وجود ندارد",
|
||||
"content": "متن پیام",
|
||||
"contentPlaceholder": "متن پیام سنجاقشده را وارد کنید...",
|
||||
"noContent": "بدون متن",
|
||||
"media": "فایل رسانه",
|
||||
"addMedia": "افزودن رسانه",
|
||||
"uploading": "در حال بارگذاری...",
|
||||
"settings": "تنظیمات",
|
||||
"sendBeforeMenu": "ارسال قبل از منو",
|
||||
"sendOnEveryStart": "ارسال در هر بار شروع",
|
||||
"broadcast": "ارسال همگانی",
|
||||
"broadcastOnCreate": "ارسال همگانی هنگام ایجاد",
|
||||
"broadcastConfirm": "پیام سنجاقشده برای همه کاربران فعال ارسال شود؟",
|
||||
"broadcastToAll": "ارسال به همه",
|
||||
"activate": "فعالسازی",
|
||||
"deactivate": "غیرفعالسازی",
|
||||
"unpin": "برداشتن سنجاق",
|
||||
"unpinAll": "برداشتن سنجاق برای همه",
|
||||
"unpinConfirm": "سنجاق پیام برای همه کاربران برداشته شود؟",
|
||||
"delete": "حذف",
|
||||
"deleteConfirm": "این پیام سنجاقشده حذف شود؟",
|
||||
"active": "فعال",
|
||||
"inactive": "غیرفعال",
|
||||
"prev": "قبلی",
|
||||
"next": "بعدی",
|
||||
"createSuccess": "پیام سنجاقشده ایجاد شد",
|
||||
"updateSuccess": "پیام بهروزرسانی شد",
|
||||
"deleteSuccess": "پیام حذف شد",
|
||||
"activateSuccess": "پیام فعال شد",
|
||||
"deactivateSuccess": "پیام غیرفعال شد",
|
||||
"broadcastSuccess": "ارسال همگانی تکمیل شد",
|
||||
"unpinSuccess": "سنجاق پیامها برداشته شد",
|
||||
"sentCount": "ارسالشده",
|
||||
"failedCount": "ناموفق",
|
||||
"unpinnedCount": "سنجاقبرداشته",
|
||||
"editMessage": "ویرایش",
|
||||
"cantDeleteActive": "نمیتوان پیام فعال را حذف کرد"
|
||||
},
|
||||
"channelSubscriptions": {
|
||||
"title": "کانالهای اجباری",
|
||||
@@ -1938,9 +1987,16 @@
|
||||
"sys_maintenance": "نگهداری",
|
||||
"sys_backup": "پشتیبانگیری",
|
||||
"sys_version": "نسخه",
|
||||
"sys_logging": "لاگها"
|
||||
"sys_logging": "لاگها",
|
||||
"iface_info": "اسناد حقوقی",
|
||||
"iface_menu": "دکمه منو",
|
||||
"sys_cabinet": "کابین",
|
||||
"sys_other": "سایر تنظیمات"
|
||||
},
|
||||
"categories": {
|
||||
"CABINET": "کابین",
|
||||
"INFO_PAGES": "اسناد حقوقی و پرسشهای متداول",
|
||||
"MENU": "دکمه منو",
|
||||
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
||||
},
|
||||
@@ -1956,7 +2012,15 @@
|
||||
"offlineConv": "تبدیلهای آفلاین",
|
||||
"apiKey": "کلید API",
|
||||
"legalFooter": "پاورقی حقوقی",
|
||||
"legalFooterDesc": "پیوند به پیشنهاد، سیاست حریم خصوصی و پرداختهای مکرر در پایین صفحه ورود"
|
||||
"legalFooterDesc": "پیوند به پیشنهاد، سیاست حریم خصوصی و پرداختهای مکرر در پایین صفحه ورود",
|
||||
"botStartVideo": "ویدیوی منوی شروع ربات",
|
||||
"botStartVideoDesc": "ربات این ویدیو را بهجای تصویر لوگو به پیام /start پیوست میکند. فایل یکبار در تلگرام بارگذاری میشود و ما فقط شناسه آن را نگه میداریم.",
|
||||
"botStartVideoUpload": "بارگذاری ویدیو",
|
||||
"botStartVideoReplace": "جایگزینی ویدیو",
|
||||
"botStartVideoRemove": "حذف",
|
||||
"botStartVideoActive": "ویدیو تنظیم شده است",
|
||||
"botStartVideoNone": "ویدیویی تنظیم نشده — منو از تصویر لوگو استفاده میکند",
|
||||
"botStartVideoError": "بارگذاری ویدیو ناموفق بود"
|
||||
},
|
||||
"buttons": {
|
||||
"color": "رنگ دکمه",
|
||||
@@ -2005,7 +2069,7 @@
|
||||
"builtinButtons": "بخشهای داخلی",
|
||||
"resetConfirm": "منو به تنظیمات پیشفرض بازنشانی شود؟ تمام تغییرات از بین میرود.",
|
||||
"buttonTextPlaceholder": "متن دکمه",
|
||||
"customLabelsHint": "متن دکمه برای هر زبان ربات",
|
||||
"customLabelsHint": "خالی = عنوان پیشفرض",
|
||||
"moveUp": "انتقال به بالا",
|
||||
"moveDown": "انتقال به پایین",
|
||||
"openIn": "باز کردن در",
|
||||
@@ -2278,7 +2342,9 @@
|
||||
"periodsRequired": "حداقل یک دوره اضافه کنید",
|
||||
"dailyPriceRequired": "قیمت روزانه بزرگتر از 0 را وارد کنید",
|
||||
"trafficPackagesRequired": "حداقل یک بسته ترافیک اضافه کنید (یا خرید ترافیک را غیرفعال کنید)"
|
||||
}
|
||||
},
|
||||
"lavaProductLabel": "محصول Lava (تمدید خودکار)",
|
||||
"lavaProductDesc": "شناسه UUID محصول از پنل Lava: قیمت و دوره کسر در آنجا تنظیم میشود. خالی — تمدید خودکار برای این تعرفه در دسترس نیست."
|
||||
},
|
||||
"servers": {
|
||||
"title": "مدیریت اسکوادها",
|
||||
@@ -2625,10 +2691,10 @@
|
||||
"programEnabled": "برنامه معرفی فعال است",
|
||||
"programEnabledDesc": "تمام عملکرد ارجاع را فعال میکند",
|
||||
"requisitesText": "متن اطلاعات حساب",
|
||||
"requisitesTextDesc": "در فرم درخواست همکاری به کاربر نمایش داده میشود",
|
||||
"requisitesTextDesc": "متن سفارشی برای توضیح به کاربران که هنگام برداشت چه اطلاعات پرداختی را وارد کنند",
|
||||
"requisitesTextPlaceholder": "اطلاعات حساب خود را وارد کنید...",
|
||||
"withdrawalEnabled": "برداشت مجاز است",
|
||||
"withdrawalEnabledDesc": "به کاربران اجازه میدهد درخواست برداشت بدهند"
|
||||
"withdrawalEnabledDesc": "اجازه دادن به همکاران برای درخواست برداشت موجودی معرفی"
|
||||
},
|
||||
"settingsLoadError": "بارگذاری تنظیمات همکاری ناموفق بود",
|
||||
"settingsSection": {
|
||||
@@ -2762,7 +2828,10 @@
|
||||
"save": "ذخیره",
|
||||
"defaultTrialTariff": "تعرفه پیشفرض آزمایشی",
|
||||
"selectTariff": "تعرفه را انتخاب کنید",
|
||||
"tariff": "تعرفه"
|
||||
"tariff": "تعرفه",
|
||||
"includeTraffic": "ترافیک",
|
||||
"trafficAmount": "حجم ترافیک",
|
||||
"gb": "گیگابایت"
|
||||
},
|
||||
"stats": {
|
||||
"title": "آمار کد تخفیف",
|
||||
@@ -2834,7 +2903,8 @@
|
||||
"daysRequired": "تعداد روزها باید بیشتر از 0 باشد",
|
||||
"groupRequired": "یک گروه تخفیف انتخاب کنید",
|
||||
"discountPercentInvalid": "درصد تخفیف باید بین 1 تا 100 باشد",
|
||||
"discountHoursRequired": "مدت اعتبار تخفیف را به ساعت مشخص کنید"
|
||||
"discountHoursRequired": "مدت اعتبار تخفیف را به ساعت مشخص کنید",
|
||||
"trafficRequired": "حجم ترافیک باید بیشتر از ۰ باشد"
|
||||
}
|
||||
},
|
||||
"promoGroups": {
|
||||
@@ -2915,7 +2985,8 @@
|
||||
"byDate": "بر اساس تاریخ",
|
||||
"byBalance": "بر اساس موجودی",
|
||||
"byActivity": "بر اساس فعالیت",
|
||||
"bySpent": "بر اساس هزینه"
|
||||
"bySpent": "بر اساس هزینه",
|
||||
"byExpiry": "بر اساس انقضا"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "نمایش {{from}}-{{to}} از {{total}}"
|
||||
@@ -3057,7 +3128,11 @@
|
||||
"sbpCancelled": "پرداخت خودکار SBP غیرفعال شد",
|
||||
"sbpStatus_PENDING": "در انتظار",
|
||||
"sbpStatus_ACTIVE": "فعال",
|
||||
"sbpStatus_PAST_DUE": "پرداخت ناموفق"
|
||||
"sbpStatus_PAST_DUE": "پرداخت ناموفق",
|
||||
"deleteTitle": "حذف اشتراک",
|
||||
"deleteHint": "اشتراک و دستگاههای آن برای همیشه حذف میشوند",
|
||||
"deleteButton": "حذف اشتراک",
|
||||
"deleted": "اشتراک حذف شد"
|
||||
},
|
||||
"balance": {
|
||||
"current": "موجودی فعلی",
|
||||
@@ -3305,7 +3380,9 @@
|
||||
"notificationsFailed": "(ناموفق: {{count}})",
|
||||
"sendError": "ارسال پیشنهاد ناموفق بود",
|
||||
"notificationsSent": "اعلانهای ارسال شده",
|
||||
"offersCreated": "پیشنهادهای ایجاد شده"
|
||||
"offersCreated": "پیشنهادهای ایجاد شده",
|
||||
"deliveryTitle": "تحویل در تلگرام",
|
||||
"openAsBroadcast": "نمایش بهعنوان ارسال گروهی"
|
||||
},
|
||||
"notFound": "قالب یافت نشد",
|
||||
"noActiveTemplates": "قالب فعالی وجود ندارد",
|
||||
@@ -3397,8 +3474,49 @@
|
||||
},
|
||||
"usersOnlineCount": "{{count}} آنلاین"
|
||||
},
|
||||
"geoCheck": {
|
||||
"title": "GeoCheck",
|
||||
"start": "اجرای بررسی",
|
||||
"running": "در حال بررسی موقعیت جغرافیایی",
|
||||
"runningHint": "نود در حال آزمایش اتصال است — معمولاً تا یک دقیقه طول میکشد.",
|
||||
"changeRoute": "تغییر مسیر",
|
||||
"rerun": "اجرای دوباره",
|
||||
"reportAlt": "گزارش GeoCheck برای {{node}}",
|
||||
"invalidIp": "یک نشانی معتبر IPv4 یا IPv6 وارد کنید",
|
||||
"invalidInterface": "یک نام رابط شبکه معتبر وارد کنید",
|
||||
"fullscreen": "تمامصفحه",
|
||||
"exitFullscreen": "خروج از تمامصفحه",
|
||||
"copyJson": "کپی گزارش JSON",
|
||||
"download": "دانلود SVG",
|
||||
"zoomIn": "بزرگنمایی",
|
||||
"zoomOut": "کوچکنمایی",
|
||||
"zoomReset": "بازنشانی بزرگنمایی",
|
||||
"mode": {
|
||||
"legend": "مسیر بررسی",
|
||||
"default": "پیشفرض",
|
||||
"ip": "نشانی IP",
|
||||
"interface": "رابط شبکه"
|
||||
},
|
||||
"hint": {
|
||||
"default": "نود از مسیر خروجی پیشفرض خود استفاده میکند.",
|
||||
"ip": "یکی از نشانیهای نود را انتخاب کنید یا هر نشانی دلخواه را وارد کنید.",
|
||||
"interface": "یکی از رابطهای نود را انتخاب کنید یا هر نامی را وارد کنید."
|
||||
},
|
||||
"placeholder": {
|
||||
"default": "خودکار"
|
||||
},
|
||||
"tab": {
|
||||
"report": "گزارش",
|
||||
"json": "JSON"
|
||||
},
|
||||
"error": {
|
||||
"title": "بررسی ناموفق بود",
|
||||
"timeout": "نود بهموقع پاسخ نداد. دوباره بررسی را اجرا کنید.",
|
||||
"generic": "انجام بررسی ممکن نشد."
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"bandwidth": "پهنای باند لحظهای",
|
||||
"bandwidth": "ترافیک ورودیها",
|
||||
"connections": "اتصالات",
|
||||
"cpu": "هستههای CPU",
|
||||
"download": "دانلود",
|
||||
@@ -4940,40 +5058,48 @@
|
||||
},
|
||||
"activateButton": "فعالسازی",
|
||||
"activateCodePlaceholder": "کد هدیه را وارد کنید",
|
||||
"activatedBy": "توسط {{name}} فعال شد",
|
||||
"activatedBy": "توسط {{username}} فعال شد",
|
||||
"activateDescription": "برای فعالسازی اشتراک، کد هدیه دریافتی را وارد کنید",
|
||||
"activatedGiftsTitle": "هدایای فعالشده",
|
||||
"activateError": "فعالسازی هدیه ناموفق بود",
|
||||
"activateSelfError": "نمیتوانید هدیه خودتان را فعال کنید",
|
||||
"activateSuccess": "هدیه فعال شد!",
|
||||
"activateSuccessDesc": "اشتراک به حساب شما افزوده شد",
|
||||
"activateSuccessDesc": "{{tariff}}: {{days}} روز به اشتراک شما اضافه شد",
|
||||
"activateTitle": "فعالسازی هدیه",
|
||||
"activeGiftsTitle": "هدایای فعال",
|
||||
"codeLabel": "کد هدیه",
|
||||
"codeReadyTitle": "کد هدیه شما آماده است",
|
||||
"copyMessage": "کپی پیام",
|
||||
"daysShort": "روز",
|
||||
"deviceCount": "تعداد دستگاهها",
|
||||
"deviceCount": "{{count}} دستگاه",
|
||||
"devicesShort": "دستگاه",
|
||||
"gbShort": "گیگابایت",
|
||||
"myGiftsEmpty": "هنوز هدیهای ندارید",
|
||||
"myGiftsEmptyDesc": "هدایایی که ارسال یا دریافت میکنید اینجا نمایش داده میشوند",
|
||||
"myGiftsEmptyDesc": "برای یک دوست هدیه بخرید یا کد دریافتی را فعال کنید",
|
||||
"pageTitle": "هدایا",
|
||||
"receivedGiftsTitle": "هدایای دریافتشده",
|
||||
"selectPeriod": "دوره را انتخاب کنید",
|
||||
"selectTariff": "تعرفه را انتخاب کنید",
|
||||
"sentTo": "به {{name}} ارسال شد",
|
||||
"sentTo": "به {{recipient}} ارسال شد",
|
||||
"shareGift": "به اشتراک گذاشتن هدیه",
|
||||
"shareModalActivateVia": "از طریق فعالسازی",
|
||||
"shareModalActivateVia": "فعالسازی از طریق ربات:",
|
||||
"shareModalActivateViaCabinet": "از طریق کابین",
|
||||
"shareText": "برای شما هدیهای از Bedolaga VPN دارم: {{code}}",
|
||||
"shareText": "برای تو یک هدیهٔ Bedolaga VPN دارم، از همینجا فعالش کن:",
|
||||
"shareToastCopied": "لینک کپی شد",
|
||||
"statusActivated": "فعال شده",
|
||||
"statusAvailable": "قابل فعالسازی",
|
||||
"tabActivate": "فعالسازی",
|
||||
"tabBuy": "خرید",
|
||||
"tabMyGifts": "هدایای من",
|
||||
"unlimitedTraffic": "ترافیک نامحدود"
|
||||
"unlimitedTraffic": "ترافیک نامحدود",
|
||||
"qrHint": "برای فعالسازی هدیه اسکن کنید",
|
||||
"scanButton": "📷 اسکن QR",
|
||||
"scanInProgress": "در حال اسکن…",
|
||||
"scanCancel": "توقف اسکن",
|
||||
"scanDescription": "دوربین را روی کد QR هدیه بگیرید",
|
||||
"scanNotRecognized": "در این QR کد هدیهای نیست",
|
||||
"scanError": "اسکن ناموفق بود",
|
||||
"scanNoCamera": "دوربین در دسترس نیست — کد را دستی وارد کنید"
|
||||
},
|
||||
"news": {
|
||||
"title": "اخبار و بهروزرسانیها",
|
||||
|
||||
@@ -210,6 +210,9 @@
|
||||
"orOpenInApp": "Или откройте бота в приложении",
|
||||
"loginFailed": "Ошибка входа",
|
||||
"telegramWidgetBlocked": "Виджет входа через Telegram недоступен. Войдите через бота:",
|
||||
"deepLinkIntro": "Подтвердите вход прямо в боте — без ввода номера телефона:",
|
||||
"loginWithBot": "Войти через бота",
|
||||
"backToWidget": "Назад к входу через виджет",
|
||||
"openBotToLogin": "Открыть бота для входа",
|
||||
"waitingForConfirmation": "Ожидание подтверждения...",
|
||||
"deepLinkExpired": "Ссылка истекла. Попробуйте снова.",
|
||||
@@ -231,6 +234,10 @@
|
||||
"sendResetLink": "Отправить ссылку",
|
||||
"tooManyAttempts": "Слишком много попыток. Попробуйте позже",
|
||||
"verificationEmailNotice": "После регистрации на вашу почту будет отправлено письмо для подтверждения",
|
||||
"legalConsentPrefix": "Я ознакомлен(а) с документом",
|
||||
"legalConsentTitle": "Ещё один шаг",
|
||||
"legalConsentSubtitle": "Чтобы создать аккаунт, подтвердите, что ознакомились с документами.",
|
||||
"legalConsentContinue": "Продолжить",
|
||||
"checkEmail": "Проверьте почту",
|
||||
"verificationSent": "Мы отправили ссылку для подтверждения на:",
|
||||
"clickLinkToVerify": "Перейдите по ссылке в письме, чтобы подтвердить аккаунт и войти.",
|
||||
@@ -720,10 +727,47 @@
|
||||
"purchaseButton": "⚡ Оформить с автооплатой СБП",
|
||||
"purchaseHint": "Первое списание подтверждает привязку в банке; продления списываются автоматически по каденсу тарифа."
|
||||
},
|
||||
"lavaRecurring": {
|
||||
"title": "Автопродление Lava",
|
||||
"connect": "Подключить",
|
||||
"payFirst": "Оплатить первый счёт",
|
||||
"cancel": "Отключить",
|
||||
"confirmCancel": "Отключить автопродление Lava? Подписка больше не будет продлеваться автоматически.",
|
||||
"cancelled": "Автопродление Lava отключено",
|
||||
"enableError": "Не удалось подключить автопродление Lava",
|
||||
"cancelError": "Не удалось отменить автопродление Lava",
|
||||
"statusPending": "Ожидает оплаты первого счёта",
|
||||
"statusActive": "Активно",
|
||||
"statusPastDue": "Платёж не прошёл — попробуйте снова",
|
||||
"nextCharge": "следующее списание {{date}}",
|
||||
"amountPerPeriod": "{{amount}} ₽ {{period}}",
|
||||
"amountPerDays": "{{amount}} ₽ раз в {{days}} дн.",
|
||||
"period": {
|
||||
"day": "ежедневно",
|
||||
"week": "еженедельно",
|
||||
"month": "ежемесячно",
|
||||
"quarter": "раз в квартал",
|
||||
"halfYear": "раз в полгода",
|
||||
"year": "ежегодно"
|
||||
},
|
||||
"autopayHint": "Подписка будет автоматически продлеваться через Lava",
|
||||
"purchaseButton": "⚡ Оформить с автооплатой Lava",
|
||||
"purchaseHint": "Первое списание оплачивается по ссылке Lava; дальше продления списываются автоматически по периоду продукта."
|
||||
},
|
||||
"backToList": "К списку подписок",
|
||||
"confirmDelete": "Удалить подписку?",
|
||||
"dailyAutoCharge": "Ежедневное списание",
|
||||
"defaultName": "Подписка",
|
||||
"connectFooter": {
|
||||
"connect": "Подключить устройство",
|
||||
"full": "Все слоты заняты",
|
||||
"connectedDevices": "Подключённые устройства",
|
||||
"disconnect": "Отключить",
|
||||
"addSlots": "Добавить слоты",
|
||||
"limitExplained": "Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите слот или добавьте ещё.",
|
||||
"limitExplainedNoTopup": "Тариф «{{name}}» даёт {{count}} слота, и все заняты. Освободите занятый слот.",
|
||||
"noTopupHint": "На тестовой подписке слоты докупить нельзя — освободите занятый или оформите платный тариф."
|
||||
},
|
||||
"delete": "Удалить",
|
||||
"deleteTitle": "Удаление подписки",
|
||||
"get_config": "Получить конфиг",
|
||||
@@ -1119,7 +1163,12 @@
|
||||
"contactSupport": "Для получения поддержки обратитесь к {{username}}",
|
||||
"contactUs": "Связаться с поддержкой",
|
||||
"create_ticket": "Создать тикет",
|
||||
"writeButton": "Написать"
|
||||
"writeButton": "Написать",
|
||||
"errors": {
|
||||
"alreadyOpenTicket": "У вас уже есть открытое обращение. Продолжите переписку в нём — новое можно создать после закрытия текущего.",
|
||||
"createFailed": "Не удалось создать обращение. Попробуйте ещё раз.",
|
||||
"replyFailed": "Не удалось отправить ответ. Попробуйте ещё раз."
|
||||
}
|
||||
},
|
||||
"wheel": {
|
||||
"title": "Колесо удачи",
|
||||
@@ -1815,6 +1864,47 @@
|
||||
},
|
||||
"usersOnlineCount": "{{count}} онлайн"
|
||||
},
|
||||
"geoCheck": {
|
||||
"title": "GeoCheck",
|
||||
"start": "Запустить проверку",
|
||||
"running": "Выполняется проверка геоданных",
|
||||
"runningHint": "Нода проверяет соединение — обычно занимает до минуты.",
|
||||
"changeRoute": "Изменить маршрут",
|
||||
"rerun": "Проверить снова",
|
||||
"reportAlt": "Отчёт GeoCheck для {{node}}",
|
||||
"invalidIp": "Введите корректный адрес IPv4 или IPv6",
|
||||
"invalidInterface": "Введите корректное имя интерфейса",
|
||||
"fullscreen": "Во весь экран",
|
||||
"exitFullscreen": "Выйти из полноэкранного режима",
|
||||
"copyJson": "Скопировать отчёт в JSON",
|
||||
"download": "Скачать SVG",
|
||||
"zoomIn": "Увеличить",
|
||||
"zoomOut": "Уменьшить",
|
||||
"zoomReset": "Сбросить масштаб",
|
||||
"mode": {
|
||||
"legend": "Маршрут проверки",
|
||||
"default": "По умолчанию",
|
||||
"ip": "IP-адрес",
|
||||
"interface": "Интерфейс"
|
||||
},
|
||||
"hint": {
|
||||
"default": "Узел будет использовать исходящий маршрут по умолчанию.",
|
||||
"ip": "Выберите один из IP-адресов узла или введите любой адрес.",
|
||||
"interface": "Выберите один из интерфейсов узла или введите любое имя."
|
||||
},
|
||||
"placeholder": {
|
||||
"default": "Автоматически"
|
||||
},
|
||||
"tab": {
|
||||
"report": "Отчёт",
|
||||
"json": "JSON"
|
||||
},
|
||||
"error": {
|
||||
"title": "Проверка не удалась",
|
||||
"timeout": "Нода не ответила вовремя. Попробуйте запустить проверку ещё раз.",
|
||||
"generic": "Не удалось выполнить проверку."
|
||||
}
|
||||
},
|
||||
"squads": {
|
||||
"synced": "Синхронизирован",
|
||||
"notSynced": "Не синхронизирован",
|
||||
@@ -2282,7 +2372,11 @@
|
||||
"sys_maintenance": "Обслуживание",
|
||||
"sys_backup": "Бэкап",
|
||||
"sys_version": "Версия",
|
||||
"sys_logging": "Логи"
|
||||
"sys_logging": "Логи",
|
||||
"iface_info": "Правовые документы",
|
||||
"iface_menu": "Кнопка меню",
|
||||
"sys_cabinet": "Кабинет",
|
||||
"sys_other": "Прочие настройки"
|
||||
},
|
||||
"presets": {
|
||||
"standard": "Стандарт",
|
||||
@@ -2545,7 +2639,7 @@
|
||||
"Traffic Daily Check Enabled": "Ежедневная проверка",
|
||||
"Traffic Daily Check Time": "Время проверки",
|
||||
"Traffic Daily Threshold Gb": "Порог в день (ГБ)",
|
||||
"Traffic Excluded User Uuids": "Исключённые UUID",
|
||||
"Traffic Excluded User Ids": "Исключённые ID пользователей панели",
|
||||
"Traffic Fast Check Enabled": "Быстрая проверка",
|
||||
"Traffic Fast Check Interval Minutes": "Интервал (мин)",
|
||||
"Traffic Fast Check Threshold Gb": "Порог (ГБ)",
|
||||
@@ -2738,6 +2832,9 @@
|
||||
"telegramOidcClientSecret": "Client Secret"
|
||||
},
|
||||
"categories": {
|
||||
"CABINET": "Кабинет",
|
||||
"INFO_PAGES": "Правовые документы и FAQ",
|
||||
"MENU": "Кнопка меню",
|
||||
"PAYMENT": "Платежи",
|
||||
"PAYMENT_VERIFICATION": "Верификация платежей",
|
||||
"YOOKASSA": "YooKassa",
|
||||
@@ -2796,7 +2893,15 @@
|
||||
"offlineConv": "Офлайн-конверсии",
|
||||
"apiKey": "API-ключ",
|
||||
"legalFooter": "Юридический футер",
|
||||
"legalFooterDesc": "Ссылки на оферту, политику и рекуррентные платежи внизу страницы входа"
|
||||
"legalFooterDesc": "Ссылки на оферту, политику и рекуррентные платежи внизу страницы входа",
|
||||
"botStartVideo": "Видео стартового меню бота",
|
||||
"botStartVideoDesc": "Бот прикрепит это видео к сообщению /start вместо картинки-логотипа. Файл разово отправляется в Telegram — храним только его идентификатор.",
|
||||
"botStartVideoUpload": "Загрузить видео",
|
||||
"botStartVideoReplace": "Заменить видео",
|
||||
"botStartVideoRemove": "Удалить",
|
||||
"botStartVideoActive": "Видео подключено",
|
||||
"botStartVideoNone": "Видео не задано — меню с картинкой-логотипом",
|
||||
"botStartVideoError": "Не удалось загрузить видео"
|
||||
},
|
||||
"buttons": {
|
||||
"color": "Цвет кнопки",
|
||||
@@ -3129,7 +3234,9 @@
|
||||
"periodsRequired": "Добавьте хотя бы один период",
|
||||
"dailyPriceRequired": "Укажите цену за день больше 0",
|
||||
"trafficPackagesRequired": "Добавьте хотя бы один пакет трафика (или отключите докупку)"
|
||||
}
|
||||
},
|
||||
"lavaProductLabel": "Продукт Lava (автопродление)",
|
||||
"lavaProductDesc": "UUID продукта из кабинета Lava: цена и периодичность списаний задаются там. Пусто — автопродление для тарифа недоступно."
|
||||
},
|
||||
"servers": {
|
||||
"title": "Управление сквадами",
|
||||
@@ -3523,7 +3630,9 @@
|
||||
"validDaysHint": "0 или пусто — бессрочно",
|
||||
"create": "Создать партию",
|
||||
"creating": "Создание…",
|
||||
"cancel": "Отмена"
|
||||
"cancel": "Отмена",
|
||||
"maxPerUser": "Лимит на пользователя",
|
||||
"maxPerUserHint": "Сколько купонов этой партии может активировать один человек. 0 — без ограничения; для раздач и конкурсов ставьте 1."
|
||||
},
|
||||
"validation": {
|
||||
"nameRequired": "Укажите название партии",
|
||||
@@ -3548,7 +3657,8 @@
|
||||
"validUntil": "Действует до",
|
||||
"perpetual": "Бессрочно",
|
||||
"createdAt": "Создана",
|
||||
"linksTitle": "Активные ссылки ({{count}} шт.)"
|
||||
"linksTitle": "Активные ссылки ({{count}} шт.)",
|
||||
"maxPerUser": "На пользователя"
|
||||
},
|
||||
"revoke": {
|
||||
"button": "Отозвать непогашенные ({{count}})",
|
||||
@@ -3561,7 +3671,16 @@
|
||||
"errors": {
|
||||
"loadFailed": "Партия не найдена",
|
||||
"createFailed": "Не удалось создать партию",
|
||||
"revokeFailed": "Не удалось отозвать партию"
|
||||
"revokeFailed": "Не удалось отозвать партию",
|
||||
"deleteFailed": "Не удалось удалить партию"
|
||||
},
|
||||
"delete": {
|
||||
"button": "🗑 Удалить партию",
|
||||
"confirmTitle": "Удалить партию?",
|
||||
"confirmText": "Будет удалено купонов: {{count}} — вместе с самой партией.",
|
||||
"redeemedWarning": "Среди них уже погашено: {{count}}. Пропадёт история активаций, выданные подписки не отзываются.",
|
||||
"confirm": "Удалить",
|
||||
"success": "Партия удалена (купонов: {{count}})"
|
||||
}
|
||||
},
|
||||
"promocodes": {
|
||||
@@ -3634,7 +3753,10 @@
|
||||
"save": "Сохранить",
|
||||
"defaultTrialTariff": "Тариф триала по умолчанию",
|
||||
"selectTariff": "Выберите тариф",
|
||||
"tariff": "Тариф"
|
||||
"tariff": "Тариф",
|
||||
"includeTraffic": "Трафик",
|
||||
"trafficAmount": "Объём трафика",
|
||||
"gb": "ГБ"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Статистика промокода",
|
||||
@@ -3710,7 +3832,8 @@
|
||||
"daysRequired": "Количество дней должно быть больше 0",
|
||||
"groupRequired": "Выберите группу скидок",
|
||||
"discountPercentInvalid": "Процент скидки должен быть от 1 до 100",
|
||||
"discountHoursRequired": "Укажите время действия скидки в часах"
|
||||
"discountHoursRequired": "Укажите время действия скидки в часах",
|
||||
"trafficRequired": "Объём трафика должен быть больше 0"
|
||||
}
|
||||
},
|
||||
"promoGroups": {
|
||||
@@ -3793,7 +3916,8 @@
|
||||
"byDate": "По дате",
|
||||
"byBalance": "По балансу",
|
||||
"byActivity": "По активности",
|
||||
"bySpent": "По расходам"
|
||||
"bySpent": "По расходам",
|
||||
"byExpiry": "По истечению подписки"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "Показано {{from}}-{{to}} из {{total}}"
|
||||
@@ -3983,7 +4107,11 @@
|
||||
"sbpCancelled": "Автоплатёж по СБП отключён",
|
||||
"sbpStatus_PENDING": "Ожидает подтверждения",
|
||||
"sbpStatus_ACTIVE": "Активен",
|
||||
"sbpStatus_PAST_DUE": "Платёж не прошёл"
|
||||
"sbpStatus_PAST_DUE": "Платёж не прошёл",
|
||||
"deleteTitle": "Удаление подписки",
|
||||
"deleteHint": "Подписка и её устройства будут удалены безвозвратно",
|
||||
"deleteButton": "Удалить подписку",
|
||||
"deleted": "Подписка удалена"
|
||||
},
|
||||
"balance": {
|
||||
"current": "Текущий баланс",
|
||||
@@ -4208,7 +4336,9 @@
|
||||
"sendError": "Не удалось отправить предложение",
|
||||
"notificationsSent": "Отправлено уведомлений",
|
||||
"offersCreated": "Создано офферов",
|
||||
"notificationsFailed": "(не отправлено: {{count}})"
|
||||
"notificationsFailed": "(не отправлено: {{count}})",
|
||||
"deliveryTitle": "Доставка в Telegram",
|
||||
"openAsBroadcast": "Открыть как рассылку"
|
||||
},
|
||||
"notFound": "Шаблон не найден",
|
||||
"noActiveTemplates": "Нет активных шаблонов",
|
||||
@@ -6007,7 +6137,15 @@
|
||||
"shareModalActivateVia": "Активировать через бота:",
|
||||
"shareModalActivateViaCabinet": "Или через сайт:",
|
||||
"copyMessage": "Скопировать сообщение",
|
||||
"shareToastCopied": "Сообщение скопировано"
|
||||
"shareToastCopied": "Сообщение скопировано",
|
||||
"qrHint": "Отсканируйте, чтобы активировать подарок",
|
||||
"scanButton": "📷 Отсканировать QR",
|
||||
"scanInProgress": "Сканирование…",
|
||||
"scanCancel": "Остановить сканирование",
|
||||
"scanDescription": "Наведите камеру на QR-код подарка",
|
||||
"scanNotRecognized": "В QR-коде нет кода подарка",
|
||||
"scanError": "Не удалось отсканировать",
|
||||
"scanNoCamera": "Камера недоступна — введите код вручную"
|
||||
},
|
||||
"news": {
|
||||
"title": "Новости и обновления",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"delete": "删除",
|
||||
"currency": "¥",
|
||||
"copy": "复制",
|
||||
"all": "All",
|
||||
"all": "全部",
|
||||
"add": "添加",
|
||||
"refresh": "刷新",
|
||||
"retry": "重试",
|
||||
@@ -199,6 +199,9 @@
|
||||
"orOpenInApp": "或在应用中打开机器人",
|
||||
"loginFailed": "登录失败",
|
||||
"telegramWidgetBlocked": "Telegram登录小部件不可用。请使用机器人登录:",
|
||||
"deepLinkIntro": "直接在机器人中确认登录 — 无需手机号:",
|
||||
"loginWithBot": "通过机器人登录",
|
||||
"backToWidget": "返回小部件登录",
|
||||
"openBotToLogin": "打开机器人登录",
|
||||
"waitingForConfirmation": "等待确认...",
|
||||
"deepLinkExpired": "链接已过期,请重试。",
|
||||
@@ -228,6 +231,10 @@
|
||||
"sendResetLink": "发送重置链接",
|
||||
"tooManyAttempts": "尝试次数过多,请稍后再试",
|
||||
"verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱",
|
||||
"legalConsentPrefix": "我已阅读",
|
||||
"legalConsentTitle": "还差一步",
|
||||
"legalConsentSubtitle": "创建账户前,请确认您已阅读以下文件。",
|
||||
"legalConsentContinue": "继续",
|
||||
"continueWithGoogle": "通过 Google 继续",
|
||||
"continueWithYandex": "通过 Yandex 继续",
|
||||
"continueWithDiscord": "通过 Discord 继续",
|
||||
@@ -276,7 +283,7 @@
|
||||
"traffic": "流量",
|
||||
"devices": "设备",
|
||||
"expiredDate": "过期时间",
|
||||
"activeUntil": "已过期 — 至 {{date}}"
|
||||
"activeUntil": "有效期至"
|
||||
},
|
||||
"deviceLimitReached": "断开设备以添加新设备",
|
||||
"suspended": {
|
||||
@@ -287,9 +294,9 @@
|
||||
"showAll": "全部显示",
|
||||
"subscriptions": "订阅",
|
||||
"connectDevice": "连接设备",
|
||||
"devicesConnectedUnlimited": "已连接 {{count}} 台设备",
|
||||
"devicesConnectedUnlimited": "已连接 {{used}} 台设备 · 无限制",
|
||||
"devicesOfMax": "{{used}} / {{max}}",
|
||||
"maxUsage": "最大",
|
||||
"maxUsage": "最大 {{amount}}",
|
||||
"remaining": "剩余",
|
||||
"stats": {
|
||||
"balance": "余额",
|
||||
@@ -298,9 +305,9 @@
|
||||
"tariff": "套餐",
|
||||
"trafficUsageTitle": "流量使用",
|
||||
"trialOffer": {
|
||||
"freeDesc": "免费试用 {{days}} 天 — 无需付款",
|
||||
"freeDesc": "免费试用我们的 VPN:无需任何承诺",
|
||||
"freeTitle": "免费试用",
|
||||
"paidDesc": "{{days}} 天试用,仅需 {{price}}",
|
||||
"paidDesc": "从试用订阅开始吧",
|
||||
"paidTitle": "付费试用"
|
||||
},
|
||||
"unlimited": "无限",
|
||||
@@ -458,7 +465,7 @@
|
||||
"newDeviceLimit": "新设备限制",
|
||||
"reduce": "减少",
|
||||
"reduceDevices": "减少设备数量",
|
||||
"reduceDevicesDescription": "调整您的设备限制。如有需要,请先断开多余设备的连接",
|
||||
"reduceDevicesDescription": "您可以将设备数量减少至套餐的最低限制",
|
||||
"reduceDevicesTitle": "减少设备数量",
|
||||
"reduceUnavailable": "当前无法减少设备数量",
|
||||
"reducing": "正在减少…"
|
||||
@@ -600,16 +607,53 @@
|
||||
"purchaseButton": "⚡ 通过SBP自动扣款订阅",
|
||||
"purchaseHint": "首次扣款即在银行应用中确认绑定;续费将按资费周期自动扣款。"
|
||||
},
|
||||
"lavaRecurring": {
|
||||
"title": "Lava 自动续订",
|
||||
"connect": "连接",
|
||||
"payFirst": "支付首期账单",
|
||||
"cancel": "关闭",
|
||||
"confirmCancel": "要关闭 Lava 自动续订吗?订阅将不再自动续订。",
|
||||
"cancelled": "已关闭 Lava 自动续订",
|
||||
"enableError": "无法开启 Lava 自动续订",
|
||||
"cancelError": "无法关闭 Lava 自动续订",
|
||||
"statusPending": "等待支付首期账单",
|
||||
"statusActive": "已启用",
|
||||
"statusPastDue": "扣款失败 — 请重试",
|
||||
"nextCharge": "下次扣款 {{date}}",
|
||||
"amountPerPeriod": "{{amount}} ₽ {{period}}",
|
||||
"amountPerDays": "每 {{days}} 天 {{amount}} ₽",
|
||||
"period": {
|
||||
"day": "每天",
|
||||
"week": "每周",
|
||||
"month": "每月",
|
||||
"quarter": "每季度",
|
||||
"halfYear": "每半年",
|
||||
"year": "每年"
|
||||
},
|
||||
"autopayHint": "订阅将通过 Lava 自动续订",
|
||||
"purchaseButton": "⚡ 使用 Lava 自动续订下单",
|
||||
"purchaseHint": "首期账单通过 Lava 链接支付;之后按产品周期自动扣款续订。"
|
||||
},
|
||||
"backToList": "返回订阅列表",
|
||||
"confirmDelete": "删除订阅?",
|
||||
"cta": {
|
||||
"renewHint": "订阅已过期 — 续订以继续使用",
|
||||
"activeHint": "订阅有效 — 您可以连接设备并使用 VPN",
|
||||
"expiredHint": "订阅已过期 — 续订以继续使用",
|
||||
"trialHint": "您正在试用中 — 升级到付费套餐以解锁所有功能"
|
||||
"activeHint": "续费和更换套餐",
|
||||
"expiredHint": "选择套餐并付款",
|
||||
"trialHint": "更多流量和设备"
|
||||
},
|
||||
"dailyAutoCharge": "每日自动扣费",
|
||||
"defaultName": "订阅",
|
||||
"connectFooter": {
|
||||
"connect": "连接设备",
|
||||
"full": "所有名额已占满",
|
||||
"connectedDevices": "已连接设备",
|
||||
"disconnect": "断开",
|
||||
"addSlots": "增加名额",
|
||||
"limitExplained": "「{{name}}」套餐提供 {{count}} 个名额,且已全部占用。请释放一个名额或增加名额。",
|
||||
"limitExplainedNoTopup": "「{{name}}」套餐提供 {{count}} 个名额,且已全部占用。请先释放一个名额。",
|
||||
"noTopupHint": "试用订阅无法购买名额 — 请释放已占用的名额或改用付费套餐。"
|
||||
},
|
||||
"delete": "删除",
|
||||
"deleteTitle": "删除订阅",
|
||||
"get_config": "获取配置",
|
||||
@@ -626,16 +670,16 @@
|
||||
"statusTrial": "试用",
|
||||
"daysShort": "天",
|
||||
"expiredBanner": {
|
||||
"selectTariff": "选择套餐",
|
||||
"selectTariff": "您的订阅已到期。请在下方选择套餐,以恢复 VPN 访问。",
|
||||
"title": "订阅已过期"
|
||||
},
|
||||
"trialInfo": {
|
||||
"description": "免费试用我们的服务 {{days}} 天",
|
||||
"remaining": "剩余 {{count}} 天",
|
||||
"description": "您正在使用服务的试用版。请在下方选择套餐以获得完整访问权限。",
|
||||
"remaining": "剩余",
|
||||
"title": "试用期"
|
||||
},
|
||||
"trialUpgrade": {
|
||||
"description": "升级到付费订阅以解锁所有功能并继续使用",
|
||||
"description": "您的试用期即将结束。请选择合适的套餐,以继续无限制使用 VPN。",
|
||||
"title": "升级订阅"
|
||||
}
|
||||
},
|
||||
@@ -973,7 +1017,12 @@
|
||||
"ticketsDisabled": "工单已禁用",
|
||||
"useExternalLink": "请使用外部链接获取支持",
|
||||
"create_ticket": "创建工单",
|
||||
"writeButton": "发消息"
|
||||
"writeButton": "发消息",
|
||||
"errors": {
|
||||
"alreadyOpenTicket": "您已有一个未关闭的工单。请在该工单中继续沟通,当前工单关闭后即可创建新工单。",
|
||||
"createFailed": "创建工单失败,请重试。",
|
||||
"replyFailed": "发送回复失败,请重试。"
|
||||
}
|
||||
},
|
||||
"wheel": {
|
||||
"title": "幸运转盘",
|
||||
@@ -1012,7 +1061,7 @@
|
||||
"noSubscription": "需要有效订阅才能转动轮盘。请先激活订阅。",
|
||||
"selectSubscription": "请先选择订阅"
|
||||
},
|
||||
"payWithStars": "支付 {stars} Stars",
|
||||
"payWithStars": "支付 Stars",
|
||||
"processingPayment": "处理中...",
|
||||
"starsPaymentSuccess": "支付成功!抽奖结果已发送到Telegram。",
|
||||
"starsPaymentFailed": "支付失败。请重试。",
|
||||
@@ -1667,7 +1716,7 @@
|
||||
},
|
||||
"broadcasts": {
|
||||
"title": "广播",
|
||||
"subtitle": "历史和管理",
|
||||
"subtitle": "向用户群发消息",
|
||||
"create": "创建群发",
|
||||
"empty": "没有广播",
|
||||
"selectFilter": "选择受众",
|
||||
@@ -2005,9 +2054,16 @@
|
||||
"sys_maintenance": "维护",
|
||||
"sys_backup": "备份",
|
||||
"sys_version": "版本",
|
||||
"sys_logging": "日志"
|
||||
"sys_logging": "日志",
|
||||
"iface_info": "法律文件",
|
||||
"iface_menu": "菜单按钮",
|
||||
"sys_cabinet": "用户中心",
|
||||
"sys_other": "其他设置"
|
||||
},
|
||||
"categories": {
|
||||
"CABINET": "用户中心",
|
||||
"INFO_PAGES": "法律文件与 FAQ",
|
||||
"MENU": "菜单按钮",
|
||||
"TELEGRAM_WIDGET": "Telegram Login Widget",
|
||||
"TELEGRAM_OIDC": "Telegram Login (OIDC)"
|
||||
},
|
||||
@@ -2023,7 +2079,15 @@
|
||||
"offlineConv": "离线转化",
|
||||
"apiKey": "API 密钥",
|
||||
"legalFooter": "法律页脚",
|
||||
"legalFooterDesc": "登录页面底部的要约、隐私政策和定期付款链接"
|
||||
"legalFooterDesc": "登录页面底部的要约、隐私政策和定期付款链接",
|
||||
"botStartVideo": "机器人开始菜单视频",
|
||||
"botStartVideoDesc": "机器人会在 /start 消息中附带该视频,替代 logo 图片。文件仅上传到 Telegram 一次,我们只保存其标识符。",
|
||||
"botStartVideoUpload": "上传视频",
|
||||
"botStartVideoReplace": "替换视频",
|
||||
"botStartVideoRemove": "删除",
|
||||
"botStartVideoActive": "已设置视频",
|
||||
"botStartVideoNone": "未设置视频 — 菜单使用 logo 图片",
|
||||
"botStartVideoError": "视频上传失败"
|
||||
},
|
||||
"buttons": {
|
||||
"color": "按钮颜色",
|
||||
@@ -2072,7 +2136,7 @@
|
||||
"builtinButtons": "内置栏目",
|
||||
"resetConfirm": "将菜单重置为默认设置?所有更改将丢失。",
|
||||
"buttonTextPlaceholder": "按钮文本",
|
||||
"customLabelsHint": "每种语言的按钮文本",
|
||||
"customLabelsHint": "留空 = 默认名称",
|
||||
"moveUp": "上移",
|
||||
"moveDown": "下移",
|
||||
"openIn": "打开方式",
|
||||
@@ -2344,7 +2408,9 @@
|
||||
"periodsRequired": "请添加至少一个期限",
|
||||
"dailyPriceRequired": "请输入大于0的每日价格",
|
||||
"trafficPackagesRequired": "请添加至少一个流量包(或关闭流量购买)"
|
||||
}
|
||||
},
|
||||
"lavaProductLabel": "Lava 产品(自动续订)",
|
||||
"lavaProductDesc": "来自 Lava 后台的产品 UUID:价格与扣款周期在那里设置。留空则该套餐不支持自动续订。"
|
||||
},
|
||||
"servers": {
|
||||
"title": "服务器管理",
|
||||
@@ -2624,10 +2690,10 @@
|
||||
"programEnabled": "推荐计划已启用",
|
||||
"programEnabledDesc": "启用整个推荐人功能",
|
||||
"requisitesText": "收款信息文本",
|
||||
"requisitesTextDesc": "将在合作申请表单中向用户显示",
|
||||
"requisitesTextDesc": "自定义提示,告知用户提现时需填写的收款信息",
|
||||
"requisitesTextPlaceholder": "请输入您的收款信息...",
|
||||
"withdrawalEnabled": "允许提款",
|
||||
"withdrawalEnabledDesc": "允许用户请求提款"
|
||||
"withdrawalEnabledDesc": "允许合作伙伴申请提现推荐余额"
|
||||
},
|
||||
"settingsLoadError": "加载推荐设置失败",
|
||||
"settingsSection": {
|
||||
@@ -2761,7 +2827,10 @@
|
||||
"save": "保存",
|
||||
"defaultTrialTariff": "默认试用套餐",
|
||||
"selectTariff": "选择套餐",
|
||||
"tariff": "套餐"
|
||||
"tariff": "套餐",
|
||||
"includeTraffic": "流量",
|
||||
"trafficAmount": "流量额度",
|
||||
"gb": "GB"
|
||||
},
|
||||
"stats": {
|
||||
"title": "促销码统计",
|
||||
@@ -2833,7 +2902,8 @@
|
||||
"daysRequired": "天数必须大于0",
|
||||
"groupRequired": "请选择折扣组",
|
||||
"discountPercentInvalid": "折扣百分比必须在1到100之间",
|
||||
"discountHoursRequired": "请指定折扣有效期(小时)"
|
||||
"discountHoursRequired": "请指定折扣有效期(小时)",
|
||||
"trafficRequired": "流量额度必须大于 0"
|
||||
}
|
||||
},
|
||||
"promoGroups": {
|
||||
@@ -2914,7 +2984,8 @@
|
||||
"byDate": "按日期",
|
||||
"byBalance": "按余额",
|
||||
"byActivity": "按活跃度",
|
||||
"bySpent": "按消费"
|
||||
"bySpent": "按消费",
|
||||
"byExpiry": "按到期时间"
|
||||
},
|
||||
"pagination": {
|
||||
"showing": "显示 {{from}}-{{to}},共 {{total}}"
|
||||
@@ -3056,7 +3127,11 @@
|
||||
"sbpCancelled": "SBP 自动扣款已关闭",
|
||||
"sbpStatus_PENDING": "待确认",
|
||||
"sbpStatus_ACTIVE": "已启用",
|
||||
"sbpStatus_PAST_DUE": "扣款失败"
|
||||
"sbpStatus_PAST_DUE": "扣款失败",
|
||||
"deleteTitle": "删除订阅",
|
||||
"deleteHint": "订阅及其设备将被永久删除",
|
||||
"deleteButton": "删除订阅",
|
||||
"deleted": "订阅已删除"
|
||||
},
|
||||
"balance": {
|
||||
"current": "当前余额",
|
||||
@@ -3304,7 +3379,9 @@
|
||||
"notificationsFailed": "(失败: {{count}})",
|
||||
"sendError": "发送优惠失败",
|
||||
"notificationsSent": "已发送通知",
|
||||
"offersCreated": "已创建优惠"
|
||||
"offersCreated": "已创建优惠",
|
||||
"deliveryTitle": "Telegram 送达情况",
|
||||
"openAsBroadcast": "作为群发查看"
|
||||
},
|
||||
"notFound": "未找到模板",
|
||||
"noActiveTemplates": "没有活跃的模板",
|
||||
@@ -3405,8 +3482,49 @@
|
||||
},
|
||||
"usersOnlineCount": "{{count}} 在线"
|
||||
},
|
||||
"geoCheck": {
|
||||
"title": "GeoCheck",
|
||||
"start": "开始检测",
|
||||
"running": "正在检测地理位置",
|
||||
"runningHint": "节点正在测试连接,通常需要一分钟以内。",
|
||||
"changeRoute": "更改路由",
|
||||
"rerun": "重新检测",
|
||||
"reportAlt": "{{node}} 的 GeoCheck 报告",
|
||||
"invalidIp": "请输入有效的 IPv4 或 IPv6 地址",
|
||||
"invalidInterface": "请输入有效的网卡名称",
|
||||
"fullscreen": "全屏",
|
||||
"exitFullscreen": "退出全屏",
|
||||
"copyJson": "复制 JSON 报告",
|
||||
"download": "下载 SVG",
|
||||
"zoomIn": "放大",
|
||||
"zoomOut": "缩小",
|
||||
"zoomReset": "重置缩放",
|
||||
"mode": {
|
||||
"legend": "检测路由",
|
||||
"default": "默认",
|
||||
"ip": "IP 地址",
|
||||
"interface": "网络接口"
|
||||
},
|
||||
"hint": {
|
||||
"default": "节点将使用默认出站路由。",
|
||||
"ip": "选择节点的某个 IP 地址,或输入任意地址。",
|
||||
"interface": "选择节点的某个网络接口,或输入任意名称。"
|
||||
},
|
||||
"placeholder": {
|
||||
"default": "自动"
|
||||
},
|
||||
"tab": {
|
||||
"report": "报告",
|
||||
"json": "JSON"
|
||||
},
|
||||
"error": {
|
||||
"title": "检测失败",
|
||||
"timeout": "节点未及时响应。请重新运行检测。",
|
||||
"generic": "无法完成检测。"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"bandwidth": "实时带宽",
|
||||
"bandwidth": "各 inbound 流量",
|
||||
"connections": "连接数",
|
||||
"cpu": "CPU核心",
|
||||
"download": "下载",
|
||||
@@ -4939,40 +5057,48 @@
|
||||
},
|
||||
"activateButton": "激活",
|
||||
"activateCodePlaceholder": "输入礼物代码",
|
||||
"activatedBy": "由 {{name}} 激活",
|
||||
"activatedBy": "由 {{username}} 激活",
|
||||
"activateDescription": "输入您收到的礼物代码以激活订阅",
|
||||
"activatedGiftsTitle": "已激活的礼物",
|
||||
"activateError": "激活礼物失败",
|
||||
"activateSelfError": "您不能激活自己的礼物",
|
||||
"activateSuccess": "礼物已激活!",
|
||||
"activateSuccessDesc": "订阅已添加到您的账户",
|
||||
"activateSuccessDesc": "{{tariff}}:已为您的订阅添加 {{days}} 天",
|
||||
"activateTitle": "激活礼物",
|
||||
"activeGiftsTitle": "活跃的礼物",
|
||||
"codeLabel": "礼物代码",
|
||||
"codeReadyTitle": "您的礼物代码已就绪",
|
||||
"copyMessage": "复制消息",
|
||||
"daysShort": "天",
|
||||
"deviceCount": "设备数",
|
||||
"deviceCount": "{{count}} 台设备",
|
||||
"devicesShort": "台",
|
||||
"gbShort": "GB",
|
||||
"myGiftsEmpty": "暂无礼物",
|
||||
"myGiftsEmptyDesc": "您发送或收到的礼物将显示在这里",
|
||||
"myGiftsEmptyDesc": "为好友购买礼物,或激活已收到的兑换码",
|
||||
"pageTitle": "礼物",
|
||||
"receivedGiftsTitle": "已收到的礼物",
|
||||
"selectPeriod": "选择时长",
|
||||
"selectTariff": "选择套餐",
|
||||
"sentTo": "已发送给 {{name}}",
|
||||
"sentTo": "已发送给 {{recipient}}",
|
||||
"shareGift": "分享礼物",
|
||||
"shareModalActivateVia": "通过以下方式激活",
|
||||
"shareModalActivateVia": "通过机器人激活:",
|
||||
"shareModalActivateViaCabinet": "通过个人中心",
|
||||
"shareText": "我为您准备了一份 Bedolaga VPN 礼物:{{code}}",
|
||||
"shareText": "我给你准备了一份 Bedolaga VPN 礼物,在这里激活它:",
|
||||
"shareToastCopied": "链接已复制",
|
||||
"statusActivated": "已激活",
|
||||
"statusAvailable": "可激活",
|
||||
"tabActivate": "激活",
|
||||
"tabBuy": "购买",
|
||||
"tabMyGifts": "我的礼物",
|
||||
"unlimitedTraffic": "无限流量"
|
||||
"unlimitedTraffic": "无限流量",
|
||||
"qrHint": "扫描以激活礼物",
|
||||
"scanButton": "📷 扫描二维码",
|
||||
"scanInProgress": "扫描中…",
|
||||
"scanCancel": "停止扫描",
|
||||
"scanDescription": "将摄像头对准礼物二维码",
|
||||
"scanNotRecognized": "该二维码中没有礼物码",
|
||||
"scanError": "扫描失败",
|
||||
"scanNoCamera": "摄像头不可用 — 请手动输入代码"
|
||||
},
|
||||
"news": {
|
||||
"title": "新闻与更新",
|
||||
|
||||
@@ -4,7 +4,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import {
|
||||
init,
|
||||
restoreInitData,
|
||||
retrieveRawInitData,
|
||||
mountMiniApp,
|
||||
miniAppReady,
|
||||
mountViewport,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
} from '@telegram-apps/sdk-react';
|
||||
import { clearStaleSessionIfNeeded } from './utils/token';
|
||||
import { installEncodingSurrogateGuard } from './utils/installEncodingSurrogateGuard';
|
||||
import { getTelegramInitData } from './utils/telegramInitData';
|
||||
import { useAuthStore } from './store/auth';
|
||||
import { AppWithNavigator } from './AppWithNavigator';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
@@ -38,7 +38,8 @@ installEncodingSurrogateGuard();
|
||||
|
||||
// Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome).
|
||||
// @telegram-apps/sdk v3 depends on valibot which uses Object.hasOwn internally.
|
||||
// Without this, init() throws LaunchParamsRetrieveError on affected devices.
|
||||
// Without this, init() and any launch-params retrieval below throw
|
||||
// LaunchParamsRetrieveError on affected devices.
|
||||
// See: https://github.com/Telegram-Mini-Apps/tma.js/issues/683
|
||||
if (typeof (Object as { hasOwn?: unknown }).hasOwn !== 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -62,7 +63,7 @@ if (isTelegramEnv && !alreadyInitialized) {
|
||||
init();
|
||||
restoreInitData();
|
||||
|
||||
clearStaleSessionIfNeeded(retrieveRawInitData() || null);
|
||||
clearStaleSessionIfNeeded(getTelegramInitData());
|
||||
|
||||
// Adopt the user's Telegram client language on first run (no explicit choice yet).
|
||||
applyTelegramLanguage();
|
||||
|
||||
@@ -3,19 +3,19 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { StatCard } from '@/components/stats';
|
||||
import {
|
||||
BanIcon,
|
||||
BroadcastDeliveryStats,
|
||||
BroadcastStatusBadge,
|
||||
} from '../components/broadcasts/BroadcastDeliveryStats';
|
||||
import { broadcastPollInterval, isBroadcastInFlight } from '../utils/broadcastStatus';
|
||||
import {
|
||||
DocumentIcon,
|
||||
EmailIcon,
|
||||
PhotoIcon,
|
||||
RefreshIcon,
|
||||
SendIcon,
|
||||
StopIcon,
|
||||
TelegramIcon,
|
||||
UsersIcon,
|
||||
VideoIcon,
|
||||
XCircleIcon,
|
||||
} from '@/components/icons';
|
||||
|
||||
// Channel badge component
|
||||
@@ -47,55 +47,6 @@ function ChannelBadge({ channel }: { channel?: BroadcastChannel }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Status badge component
|
||||
const statusConfig: Record<string, { bg: string; text: string; labelKey: string }> = {
|
||||
queued: {
|
||||
bg: 'bg-warning-500/20',
|
||||
text: 'text-warning-400',
|
||||
labelKey: 'admin.broadcasts.status.queued',
|
||||
},
|
||||
in_progress: {
|
||||
bg: 'bg-accent-500/20',
|
||||
text: 'text-accent-400',
|
||||
labelKey: 'admin.broadcasts.status.inProgress',
|
||||
},
|
||||
completed: {
|
||||
bg: 'bg-success-500/20',
|
||||
text: 'text-success-400',
|
||||
labelKey: 'admin.broadcasts.status.completed',
|
||||
},
|
||||
partial: {
|
||||
bg: 'bg-warning-500/20',
|
||||
text: 'text-warning-400',
|
||||
labelKey: 'admin.broadcasts.status.partial',
|
||||
},
|
||||
failed: {
|
||||
bg: 'bg-error-500/20',
|
||||
text: 'text-error-400',
|
||||
labelKey: 'admin.broadcasts.status.failed',
|
||||
},
|
||||
cancelled: {
|
||||
bg: 'bg-dark-500/20',
|
||||
text: 'text-dark-400',
|
||||
labelKey: 'admin.broadcasts.status.cancelled',
|
||||
},
|
||||
cancelling: {
|
||||
bg: 'bg-warning-500/20',
|
||||
text: 'text-warning-400',
|
||||
labelKey: 'admin.broadcasts.status.cancelling',
|
||||
},
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const { t } = useTranslation();
|
||||
const config = statusConfig[status] || statusConfig.queued;
|
||||
return (
|
||||
<span className={`rounded-full px-3 py-1 text-sm font-medium ${config.bg} ${config.text}`}>
|
||||
{t(config.labelKey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminBroadcastDetail() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -116,13 +67,7 @@ export default function AdminBroadcastDetail() {
|
||||
return adminBroadcastsApi.get(broadcastId);
|
||||
},
|
||||
enabled: !!broadcastId && !isNaN(broadcastId),
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data;
|
||||
if (data && ['queued', 'in_progress', 'cancelling'].includes(data.status)) {
|
||||
return 3000;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
refetchInterval: (query) => broadcastPollInterval(query.state.data?.status),
|
||||
});
|
||||
|
||||
// Stop mutation
|
||||
@@ -134,7 +79,7 @@ export default function AdminBroadcastDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const isRunning = broadcast && ['queued', 'in_progress', 'cancelling'].includes(broadcast.status);
|
||||
const isRunning = broadcast && isBroadcastInFlight(broadcast.status);
|
||||
|
||||
if (!broadcastId || isNaN(broadcastId)) {
|
||||
navigate('/admin/broadcasts');
|
||||
@@ -174,7 +119,7 @@ export default function AdminBroadcastDetail() {
|
||||
<h1 className="text-xl font-bold text-dark-100">
|
||||
{t('admin.broadcasts.detail')} #{broadcast.id}
|
||||
</h1>
|
||||
<StatusBadge status={broadcast.status} />
|
||||
<BroadcastStatusBadge status={broadcast.status} />
|
||||
<ChannelBadge channel={broadcast.channel} />
|
||||
</div>
|
||||
<p className="text-sm text-dark-400">
|
||||
@@ -190,51 +135,15 @@ export default function AdminBroadcastDetail() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{isRunning && (
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
<div className="mb-2 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{broadcast.progress_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent-500 to-accent-400 transition-all duration-300"
|
||||
style={{ width: `${broadcast.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.total')}
|
||||
value={broadcast.total_count}
|
||||
icon={<UsersIcon className="h-5 w-5" />}
|
||||
tone="neutral"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.sent')}
|
||||
value={broadcast.sent_count}
|
||||
icon={<SendIcon className="h-5 w-5" />}
|
||||
tone="success"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.blocked')}
|
||||
value={broadcast.blocked_count}
|
||||
icon={<BanIcon className="h-5 w-5" />}
|
||||
tone="warning"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.broadcasts.failed')}
|
||||
value={broadcast.failed_count}
|
||||
icon={<XCircleIcon className="h-5 w-5" />}
|
||||
tone="error"
|
||||
/>
|
||||
</div>
|
||||
{/* Progress + stats */}
|
||||
<BroadcastDeliveryStats
|
||||
status={broadcast.status}
|
||||
progressPercent={broadcast.progress_percent}
|
||||
totalCount={broadcast.total_count}
|
||||
sentCount={broadcast.sent_count}
|
||||
blockedCount={broadcast.blocked_count}
|
||||
failedCount={broadcast.failed_count}
|
||||
/>
|
||||
|
||||
{/* Target */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-4">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { createNumberInputHandler } from '../utils/inputHelpers';
|
||||
import { couponsApi, CouponBatchCreated } from '../api/coupons';
|
||||
import { couponsApi, type CouponBatchCreated } from '../api/coupons';
|
||||
import { tariffsApi } from '../api/tariffs';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
import { copyToClipboard } from '../utils/clipboard';
|
||||
@@ -33,6 +33,7 @@ export default function AdminCouponCreate() {
|
||||
const [couponsCount, setCouponsCount] = useState<number | ''>(50);
|
||||
const [priceRubles, setPriceRubles] = useState<number | ''>('');
|
||||
const [validDays, setValidDays] = useState<number | ''>('');
|
||||
const [maxPerUser, setMaxPerUser] = useState<number | ''>('');
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
|
||||
@@ -77,6 +78,7 @@ export default function AdminCouponCreate() {
|
||||
coupons_count: Number(couponsCount),
|
||||
wholesale_price_kopeks: priceRubles === '' ? 0 : Math.round(Number(priceRubles) * 100),
|
||||
valid_days: validDays === '' ? 0 : Number(validDays),
|
||||
max_per_user: maxPerUser === '' ? 0 : Number(maxPerUser),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -275,6 +277,21 @@ export default function AdminCouponCreate() {
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.validDaysHint')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-dark-300">
|
||||
{t('admin.coupons.form.maxPerUser')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxPerUser}
|
||||
onChange={createNumberInputHandler(setMaxPerUser, 0, 500)}
|
||||
min={0}
|
||||
max={500}
|
||||
placeholder="0"
|
||||
className="input w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">{t('admin.coupons.form.maxPerUserHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
@@ -31,6 +31,7 @@ export default function AdminCouponDetail() {
|
||||
|
||||
const batchId = Number(id);
|
||||
const [revokeConfirm, setRevokeConfirm] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const { data: batch, isLoading } = useQuery({
|
||||
@@ -70,6 +71,28 @@ export default function AdminCouponDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => couponsApi.deleteBatch(batchId),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-coupon-batches'] });
|
||||
setDeleteConfirm(false);
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: t('admin.coupons.delete.success', { count: result.deleted_coupons }),
|
||||
message: '',
|
||||
});
|
||||
navigate('/admin/coupons', { replace: true });
|
||||
},
|
||||
onError: (err) => {
|
||||
setDeleteConfirm(false);
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: t('admin.coupons.errors.deleteFailed'),
|
||||
message: getApiErrorMessage(err, ''),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleCopyAll = () => {
|
||||
if (!links) return;
|
||||
void copyToClipboard(links.links.join('\n'));
|
||||
@@ -169,6 +192,12 @@ export default function AdminCouponDetail() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{batch.max_per_user > 0 && (
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-dark-400">{t('admin.coupons.detail.maxPerUser')}</span>
|
||||
<span className="text-dark-100">{batch.max_per_user}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between gap-4">
|
||||
<span className="text-dark-400">{t('admin.coupons.detail.validUntil')}</span>
|
||||
<span className="text-dark-100">
|
||||
@@ -228,6 +257,49 @@ export default function AdminCouponDetail() {
|
||||
</PermissionGate>
|
||||
)}
|
||||
|
||||
{/* Delete — полностью убирает партию, в отличие от отзыва */}
|
||||
<PermissionGate permission="coupons:edit" fallback={null}>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(true)}
|
||||
className="w-full rounded-lg border border-error-500/30 px-4 py-2.5 text-error-400 transition-colors hover:bg-error-500/20"
|
||||
>
|
||||
{t('admin.coupons.delete.button')}
|
||||
</button>
|
||||
</PermissionGate>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{deleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4">
|
||||
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{t('admin.coupons.delete.confirmTitle')}
|
||||
</h3>
|
||||
<p className="mb-2 text-dark-400">
|
||||
{t('admin.coupons.delete.confirmText', { count: batch.coupons_total })}
|
||||
</p>
|
||||
{batch.redeemed_count > 0 && (
|
||||
<p className="mb-4 text-sm text-warning-400">
|
||||
{t('admin.coupons.delete.redeemedWarning', { count: batch.redeemed_count })}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setDeleteConfirm(false)} className="btn-secondary flex-1">
|
||||
{t('admin.coupons.revoke.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
disabled={deleteMutation.isPending}
|
||||
className={`flex-1 rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600 ${
|
||||
deleteMutation.isPending ? 'cursor-not-allowed opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{t('admin.coupons.delete.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Revoke confirmation */}
|
||||
{revokeConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-dark-950/70 p-4">
|
||||
|
||||
@@ -4,14 +4,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
promoOffersApi,
|
||||
PromoOfferBroadcastRequest,
|
||||
type PromoOfferBroadcastRequest,
|
||||
TARGET_SEGMENTS,
|
||||
TargetSegment,
|
||||
type TargetSegment,
|
||||
OFFER_TYPE_CONFIG,
|
||||
OfferType,
|
||||
type OfferType,
|
||||
} from '../api/promoOffers';
|
||||
import { adminUsersApi, UserListItem } from '../api/adminUsers';
|
||||
import { adminBroadcastsApi } from '../api/adminBroadcasts';
|
||||
import { adminUsersApi, type UserListItem } from '../api/adminUsers';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import {
|
||||
BroadcastDeliveryStats,
|
||||
BroadcastStatusBadge,
|
||||
} from '../components/broadcasts/BroadcastDeliveryStats';
|
||||
import { broadcastPollInterval } from '../utils/broadcastStatus';
|
||||
import {
|
||||
SendIcon,
|
||||
CheckIcon,
|
||||
@@ -43,6 +49,7 @@ export default function AdminPromoOfferSend() {
|
||||
title: string;
|
||||
message: string;
|
||||
isSuccess: boolean;
|
||||
broadcastId?: number | null;
|
||||
} | null>(null);
|
||||
|
||||
// Query templates
|
||||
@@ -51,6 +58,27 @@ export default function AdminPromoOfferSend() {
|
||||
queryFn: promoOffersApi.getTemplates,
|
||||
});
|
||||
|
||||
// Recipient counts per segment — админ видит охват до отправки
|
||||
const { data: segmentsData } = useQuery({
|
||||
queryKey: ['admin-promo-segments'],
|
||||
queryFn: promoOffersApi.getSegments,
|
||||
staleTime: 60000,
|
||||
});
|
||||
|
||||
const segmentCounts = new Map(
|
||||
(segmentsData?.segments || []).map((segment) => [segment.key, segment.count]),
|
||||
);
|
||||
const selectedSegmentCount = segmentCounts.get(selectedTarget);
|
||||
|
||||
// Delivery progress of the offer we have just sent
|
||||
const broadcastId = result?.broadcastId ?? null;
|
||||
const { data: delivery } = useQuery({
|
||||
queryKey: ['admin', 'broadcasts', 'detail', broadcastId],
|
||||
queryFn: async () => adminBroadcastsApi.get(broadcastId as number),
|
||||
enabled: broadcastId !== null,
|
||||
refetchInterval: (query) => broadcastPollInterval(query.state.data?.status),
|
||||
});
|
||||
|
||||
const templates = templatesData?.items || [];
|
||||
const activeTemplates = templates.filter((t) => t.is_active);
|
||||
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
|
||||
@@ -102,6 +130,7 @@ export default function AdminPromoOfferSend() {
|
||||
mutationFn: promoOffersApi.broadcastOffer,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
|
||||
|
||||
let message = t('admin.promoOffers.result.offersCreated', { count: data.created_offers });
|
||||
if (data.notifications_sent > 0 || data.notifications_failed > 0) {
|
||||
@@ -121,6 +150,7 @@ export default function AdminPromoOfferSend() {
|
||||
title: t('admin.promoOffers.result.sentTitle'),
|
||||
message,
|
||||
isSuccess: true,
|
||||
broadcastId: data.broadcast_id,
|
||||
});
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
@@ -182,7 +212,7 @@ export default function AdminPromoOfferSend() {
|
||||
if (result) {
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mx-auto max-w-md py-12 text-center">
|
||||
<div className="mx-auto max-w-2xl py-12 text-center">
|
||||
<div
|
||||
className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full ${
|
||||
result.isSuccess ? 'bg-success-500/20' : 'bg-error-500/20'
|
||||
@@ -196,6 +226,33 @@ export default function AdminPromoOfferSend() {
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">{result.title}</h3>
|
||||
<p className="mb-6 whitespace-pre-wrap text-dark-400">{result.message}</p>
|
||||
|
||||
{/* Прогресс доставки в Telegram: сколько дошло, кто заблокировал бота */}
|
||||
{delivery && (
|
||||
<div className="mb-6 space-y-4 text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-dark-300">
|
||||
{t('admin.promoOffers.result.deliveryTitle')}
|
||||
</span>
|
||||
<BroadcastStatusBadge status={delivery.status} />
|
||||
</div>
|
||||
<BroadcastDeliveryStats
|
||||
status={delivery.status}
|
||||
progressPercent={delivery.progress_percent}
|
||||
totalCount={delivery.total_count}
|
||||
sentCount={delivery.sent_count}
|
||||
blockedCount={delivery.blocked_count}
|
||||
failedCount={delivery.failed_count}
|
||||
/>
|
||||
<button
|
||||
onClick={() => navigate(`/admin/broadcasts/${delivery.id}`)}
|
||||
className="text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
{t('admin.promoOffers.result.openAsBroadcast')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/admin/promo-offers')}
|
||||
@@ -324,17 +381,30 @@ export default function AdminPromoOfferSend() {
|
||||
</div>
|
||||
|
||||
{sendMode === 'segment' ? (
|
||||
<select
|
||||
value={selectedTarget}
|
||||
onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
|
||||
className="input"
|
||||
>
|
||||
{Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => (
|
||||
<option key={key} value={key}>
|
||||
{t(labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<>
|
||||
<select
|
||||
value={selectedTarget}
|
||||
onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
|
||||
className="input"
|
||||
>
|
||||
{Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => {
|
||||
const count = segmentCounts.get(key);
|
||||
return (
|
||||
<option key={key} value={key}>
|
||||
{count === undefined
|
||||
? t(labelKey)
|
||||
: `${t(labelKey)} — ${count} ${t('admin.broadcasts.recipients')}`}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{selectedSegmentCount !== undefined && (
|
||||
<div className="mt-2 text-sm text-dark-400">
|
||||
{t('admin.broadcasts.willBeSent')}:{' '}
|
||||
<strong className="text-accent-400">{selectedSegmentCount}</strong>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div ref={searchRef} className="relative">
|
||||
{selectedUser ? (
|
||||
|
||||
@@ -44,6 +44,8 @@ export default function AdminPromocodeCreate() {
|
||||
const [includeBalance, setIncludeBalance] = useState(true);
|
||||
const [includeDays, setIncludeDays] = useState(false);
|
||||
const [includeGroup, setIncludeGroup] = useState(false);
|
||||
const [includeTraffic, setIncludeTraffic] = useState(false);
|
||||
const [trafficGb, setTrafficGb] = useState<number | ''>(0);
|
||||
const [balanceBonusRubles, setBalanceBonusRubles] = useState<number | ''>(0);
|
||||
const [subscriptionDays, setSubscriptionDays] = useState<number | ''>(0);
|
||||
const [maxUses, setMaxUses] = useState<number | ''>(1);
|
||||
@@ -85,8 +87,15 @@ export default function AdminPromocodeCreate() {
|
||||
setMode(data.type);
|
||||
} else {
|
||||
setMode('bonus_set');
|
||||
setIncludeBalance(data.type === 'balance' || data.type === 'balance_and_days');
|
||||
setIncludeDays(data.type === 'subscription_days' || data.type === 'balance_and_days');
|
||||
// Галочки поднимаем по ЗНАЧЕНИЯМ, а не по типу. С появлением трафика
|
||||
// balance_and_days перестал означать «есть и баланс, и дни»: этот тип
|
||||
// теперь у любого набора с трафиком, в том числе с нулевым балансом и
|
||||
// нулём дней. По типу такой код открывался бы с двумя чужими галочками
|
||||
// на нулях, валидация требовала бы «больше 0», и Сохранить не работало
|
||||
// бы, пока админ их не снимет, — а подсказка толкает вместо этого
|
||||
// вписать сумму и молча добавить коду бонус, которого в нём не было.
|
||||
setIncludeBalance(data.type === 'balance' || (data.balance_bonus_rubles || 0) > 0);
|
||||
setIncludeDays(data.type === 'subscription_days' || (data.subscription_days || 0) > 0);
|
||||
// Промогруппа комбинируется с любым составом (bэкенд назначает её
|
||||
// независимо от типа), поэтому чекбокс — по факту наличия группы
|
||||
setIncludeGroup(data.type === 'promo_group' || !!data.promo_group_id);
|
||||
@@ -99,6 +108,8 @@ export default function AdminPromocodeCreate() {
|
||||
setBalanceBonusRubles(data.balance_bonus_rubles || 0);
|
||||
}
|
||||
setSubscriptionDays(data.subscription_days || 0);
|
||||
setTrafficGb(data.traffic_gb || 0);
|
||||
setIncludeTraffic((data.traffic_gb || 0) > 0);
|
||||
setMaxUses(data.max_uses || 1);
|
||||
setIsActive(data.is_active ?? true);
|
||||
setFirstPurchaseOnly(data.first_purchase_only || false);
|
||||
@@ -133,7 +144,7 @@ export default function AdminPromocodeCreate() {
|
||||
// едет через promo_group_id при любом типе (бэкенд применяет её независимо).
|
||||
const derivedType: PromoCodeType =
|
||||
mode === 'bonus_set'
|
||||
? includeBalance && includeDays
|
||||
? includeTraffic || (includeBalance && includeDays)
|
||||
? 'balance_and_days'
|
||||
: includeBalance
|
||||
? 'balance'
|
||||
@@ -148,6 +159,7 @@ export default function AdminPromocodeCreate() {
|
||||
const balanceValue = balanceBonusRubles === '' ? 0 : balanceBonusRubles;
|
||||
const daysValue = subscriptionDays === '' ? 0 : subscriptionDays;
|
||||
const maxUsesValue = maxUses === '' ? 0 : maxUses;
|
||||
const trafficValue = trafficGb === '' ? 0 : trafficGb;
|
||||
|
||||
const data: PromoCodeCreateRequest | PromoCodeUpdateRequest = {
|
||||
code: code.trim().toUpperCase(),
|
||||
@@ -164,6 +176,7 @@ export default function AdminPromocodeCreate() {
|
||||
(mode === 'bonus_set' && includeDays)
|
||||
? daysValue
|
||||
: 0,
|
||||
traffic_gb: mode === 'bonus_set' && includeTraffic ? trafficValue : 0,
|
||||
max_uses: maxUsesValue,
|
||||
is_active: isActive,
|
||||
first_purchase_only: firstPurchaseOnly,
|
||||
@@ -197,7 +210,7 @@ export default function AdminPromocodeCreate() {
|
||||
validationErrors.push('codeRequired');
|
||||
}
|
||||
if (mode === 'bonus_set') {
|
||||
if (!includeBalance && !includeDays && !includeGroup) {
|
||||
if (!includeBalance && !includeDays && !includeGroup && !includeTraffic) {
|
||||
validationErrors.push('bonusSetEmpty');
|
||||
}
|
||||
if (includeBalance && balanceValue <= 0) {
|
||||
@@ -209,6 +222,9 @@ export default function AdminPromocodeCreate() {
|
||||
if (includeGroup && !promoGroupId) {
|
||||
validationErrors.push('groupRequired');
|
||||
}
|
||||
if (includeTraffic && (trafficGb === '' || trafficGb <= 0)) {
|
||||
validationErrors.push('trafficRequired');
|
||||
}
|
||||
}
|
||||
if (mode === 'trial_subscription' && daysValue <= 0) {
|
||||
validationErrors.push('daysRequired');
|
||||
@@ -308,6 +324,7 @@ export default function AdminPromocodeCreate() {
|
||||
[
|
||||
['includeBalance', includeBalance, setIncludeBalance] as const,
|
||||
['includeDays', includeDays, setIncludeDays] as const,
|
||||
['includeTraffic', includeTraffic, setIncludeTraffic] as const,
|
||||
['includePromoGroup', includeGroup, setIncludeGroup] as const,
|
||||
] as const
|
||||
).map(([key, checked, setChecked]) => (
|
||||
@@ -361,6 +378,28 @@ export default function AdminPromocodeCreate() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'bonus_set' && includeTraffic && (
|
||||
<div>
|
||||
<label htmlFor="pc-traffic-gb" className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.promocodes.form.trafficAmount')}
|
||||
<span className="text-error-400">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="pc-traffic-gb"
|
||||
type="number"
|
||||
value={trafficGb}
|
||||
onChange={createNumberInputHandler(setTrafficGb, 0)}
|
||||
className="input w-32"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="text-dark-400">{t('admin.promocodes.form.gb')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(mode === 'trial_subscription' || (mode === 'bonus_set' && includeDays)) && (
|
||||
<div>
|
||||
<label htmlFor="pc-sub-days" className="mb-2 block text-sm font-medium text-dark-300">
|
||||
|
||||
@@ -196,16 +196,27 @@ export default function AdminPromocodes() {
|
||||
</div>
|
||||
{/* Info line */}
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
||||
{(promo.type === 'balance' || promo.type === 'balance_and_days') && (
|
||||
<span className="text-success-400">
|
||||
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
|
||||
</span>
|
||||
)}
|
||||
{/* Составляющие показываем по значению, а не по типу:
|
||||
balance_and_days теперь стоит и у набора, где баланса
|
||||
или дней нет вовсе, — иначе в списке висело бы «+0 ₽
|
||||
+0 дн.» у кода, который на самом деле раздаёт трафик. */}
|
||||
{(promo.type === 'balance' || promo.type === 'balance_and_days') &&
|
||||
promo.balance_bonus_rubles > 0 && (
|
||||
<span className="text-success-400">
|
||||
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
|
||||
</span>
|
||||
)}
|
||||
{(promo.type === 'subscription_days' ||
|
||||
promo.type === 'trial_subscription' ||
|
||||
promo.type === 'balance_and_days') && (
|
||||
promo.type === 'balance_and_days') &&
|
||||
promo.subscription_days > 0 && (
|
||||
<span className="text-accent-400">
|
||||
+{promo.subscription_days} {t('admin.promocodes.form.days')}
|
||||
</span>
|
||||
)}
|
||||
{promo.type === 'balance_and_days' && (promo.traffic_gb || 0) > 0 && (
|
||||
<span className="text-accent-400">
|
||||
+{promo.subscription_days} {t('admin.promocodes.form.days')}
|
||||
+{promo.traffic_gb} {t('admin.promocodes.form.gb')}
|
||||
</span>
|
||||
)}
|
||||
{promo.type === 'discount' && (
|
||||
|
||||
@@ -4,16 +4,16 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
adminRemnawaveApi,
|
||||
NodeInfo,
|
||||
NodeRealtimeStats,
|
||||
SquadWithLocalInfo,
|
||||
SystemStatsResponse,
|
||||
AutoSyncStatus,
|
||||
RecapResponse,
|
||||
DevicesStatsResponse,
|
||||
TopConsumersResponse,
|
||||
HealthResponse,
|
||||
SubscriptionRequestStatsResponse,
|
||||
type NodeInfo,
|
||||
type NodeRealtimeStats,
|
||||
type SquadWithLocalInfo,
|
||||
type SystemStatsResponse,
|
||||
type AutoSyncStatus,
|
||||
type RecapResponse,
|
||||
type DevicesStatsResponse,
|
||||
type TopConsumersResponse,
|
||||
type HealthResponse,
|
||||
type SubscriptionRequestStatsResponse,
|
||||
} from '../api/adminRemnawave';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
import { formatUptime } from '../utils/format';
|
||||
@@ -54,14 +54,17 @@ import {
|
||||
SubscriptionIcon,
|
||||
BackIcon,
|
||||
ChevronRightIcon,
|
||||
GeoCheckIcon,
|
||||
} from '../components/icons';
|
||||
import { GeoCheckModal } from '../components/admin/remnawave/GeoCheckModal';
|
||||
import { supportsGeoCheck } from '../utils/nodeVersion';
|
||||
|
||||
const formatBytes = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
return parseFloat((bytes / k ** i).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Алгоритмический ISO 3166-1 alpha-2 → regional indicator. Глобус-fallback
|
||||
@@ -149,6 +152,11 @@ interface NodeCardProps {
|
||||
function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [geoCheckOpen, setGeoCheckOpen] = useState(false);
|
||||
|
||||
// GeoCheck умеет только узел 3.3.0+; на старом узле кнопку не показываем,
|
||||
// чтобы админ не упирался в ошибку панели.
|
||||
const canGeoCheck = supportsGeoCheck(node.versions);
|
||||
|
||||
const isUp = node.is_connected && node.is_node_online && !node.is_disabled;
|
||||
const dotColor = node.is_disabled ? 'bg-dark-500' : isUp ? 'bg-success-400' : 'bg-error-400';
|
||||
@@ -193,245 +201,266 @@ function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCar
|
||||
? 'text-warning-400'
|
||||
: 'text-dark-400';
|
||||
|
||||
// Модалка — сосед кликабельного блока, а не его потомок: портал уносит её
|
||||
// в document.body только по DOM, а события React прогоняет по дереву
|
||||
// компонентов, и клики внутри неё всплывали бы в onClick карточки.
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border border-dark-700 bg-dark-800/50 p-3.5 transition-colors hover:border-dark-600 ${
|
||||
hasBreakdown ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={hasBreakdown ? () => setExpanded((v) => !v) : undefined}
|
||||
>
|
||||
{/* Identity + actions */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${dotColor} ${isUp ? 'animate-pulse' : ''}`}
|
||||
title={statusText}
|
||||
/>
|
||||
<span className="flex shrink-0 items-center gap-1 rounded-md bg-dark-700/60 px-1.5 py-0.5 text-[11px] text-dark-300">
|
||||
<UsersIcon className="h-3 w-3" />
|
||||
{node.users_online ?? 0}
|
||||
</span>
|
||||
<span className="shrink-0 text-base leading-none">
|
||||
{getCountryFlag(node.country_code)}
|
||||
</span>
|
||||
<h3 className="truncate font-semibold text-dark-100">{node.name}</h3>
|
||||
{(providerLabel || providerFavicon) && (
|
||||
<span className="flex min-w-0 max-w-[7rem] shrink items-center gap-1 rounded-md bg-accent-500/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-accent-300">
|
||||
{providerFavicon && (
|
||||
<img
|
||||
src={providerFavicon}
|
||||
alt=""
|
||||
className="h-3 w-3 shrink-0 rounded-[2px]"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{providerLabel && <span className="truncate">{providerLabel}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAction(node.uuid, 'restart');
|
||||
}}
|
||||
disabled={isLoading || node.is_disabled}
|
||||
className="rounded-lg bg-dark-700 p-1.5 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={t('admin.remnawave.nodes.restart', 'Restart')}
|
||||
>
|
||||
<ArrowPathIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAction(node.uuid, node.is_disabled ? 'enable' : 'disable');
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className={`rounded-lg p-1.5 transition-colors disabled:opacity-50 ${
|
||||
node.is_disabled
|
||||
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
||||
: 'bg-error-500/20 text-error-400 hover:bg-error-500/30'
|
||||
}`}
|
||||
title={
|
||||
node.is_disabled
|
||||
? t('admin.remnawave.nodes.enable', 'Enable')
|
||||
: t('admin.remnawave.nodes.disable', 'Disable')
|
||||
}
|
||||
>
|
||||
{node.is_disabled ? (
|
||||
<PlayIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<StopIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{hasBreakdown && (
|
||||
<ChevronRightIcon
|
||||
className={`h-4 w-4 text-dark-500 transition-transform ${
|
||||
expanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
<>
|
||||
<div
|
||||
className={`rounded-xl border border-dark-700 bg-dark-800/50 p-3.5 transition-colors hover:border-dark-600 ${
|
||||
hasBreakdown ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={hasBreakdown ? () => setExpanded((v) => !v) : undefined}
|
||||
>
|
||||
{/* Identity + actions */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${dotColor} ${isUp ? 'animate-pulse' : ''}`}
|
||||
title={statusText}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="flex shrink-0 items-center gap-1 rounded-md bg-dark-700/60 px-1.5 py-0.5 text-[11px] text-dark-300">
|
||||
<UsersIcon className="h-3 w-3" />
|
||||
{node.users_online ?? 0}
|
||||
</span>
|
||||
<span className="shrink-0 text-base leading-none">
|
||||
{getCountryFlag(node.country_code)}
|
||||
</span>
|
||||
<h3 className="truncate font-semibold text-dark-100">{node.name}</h3>
|
||||
{(providerLabel || providerFavicon) && (
|
||||
<span className="flex min-w-0 max-w-[7rem] shrink items-center gap-1 rounded-md bg-accent-500/15 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-accent-300">
|
||||
{providerFavicon && (
|
||||
<img
|
||||
src={providerFavicon}
|
||||
alt=""
|
||||
className="h-3 w-3 shrink-0 rounded-[2px]"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{providerLabel && <span className="truncate">{providerLabel}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address + traffic + uptime */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-dark-400">
|
||||
<span className="flex min-w-0 max-w-full items-center gap-1 font-mono text-dark-500">
|
||||
<GlobeIcon className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{node.address}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-dark-300">{formatBytes(used)}</span>
|
||||
{trafficPct !== null && (
|
||||
<span className="h-1 w-16 overflow-hidden rounded-full bg-dark-700">
|
||||
<span
|
||||
className="block h-full rounded-full bg-accent-500"
|
||||
style={{ width: `${trafficPct}%` }}
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{canGeoCheck && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setGeoCheckOpen(true);
|
||||
}}
|
||||
disabled={node.is_disabled || !node.is_connected}
|
||||
className="rounded-lg bg-dark-700 p-1.5 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={t('admin.remnawave.geoCheck.title', 'GeoCheck')}
|
||||
aria-label={t('admin.remnawave.geoCheck.title', 'GeoCheck')}
|
||||
>
|
||||
<GeoCheckIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAction(node.uuid, 'restart');
|
||||
}}
|
||||
disabled={isLoading || node.is_disabled}
|
||||
className="rounded-lg bg-dark-700 p-1.5 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
title={t('admin.remnawave.nodes.restart', 'Restart')}
|
||||
>
|
||||
<ArrowPathIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAction(node.uuid, node.is_disabled ? 'enable' : 'disable');
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className={`rounded-lg p-1.5 transition-colors disabled:opacity-50 ${
|
||||
node.is_disabled
|
||||
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
||||
: 'bg-error-500/20 text-error-400 hover:bg-error-500/30'
|
||||
}`}
|
||||
title={
|
||||
node.is_disabled
|
||||
? t('admin.remnawave.nodes.enable', 'Enable')
|
||||
: t('admin.remnawave.nodes.disable', 'Disable')
|
||||
}
|
||||
>
|
||||
{node.is_disabled ? (
|
||||
<PlayIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<StopIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{hasBreakdown && (
|
||||
<ChevronRightIcon
|
||||
className={`h-4 w-4 text-dark-500 transition-transform ${
|
||||
expanded ? 'rotate-90' : ''
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address + traffic + uptime */}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-dark-400">
|
||||
<span className="flex min-w-0 max-w-full items-center gap-1 font-mono text-dark-500">
|
||||
<GlobeIcon className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{node.address}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="text-dark-300">{formatBytes(used)}</span>
|
||||
{trafficPct !== null && (
|
||||
<span className="h-1 w-16 overflow-hidden rounded-full bg-dark-700">
|
||||
<span
|
||||
className="block h-full rounded-full bg-accent-500"
|
||||
style={{ width: `${trafficPct}%` }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-dark-500">/ {limit > 0 ? formatBytes(limit) : '∞'}</span>
|
||||
</span>
|
||||
{node.xray_uptime > 0 && (
|
||||
<span className="flex items-center gap-1 text-dark-500">
|
||||
<StatUptimeIcon className="h-3 w-3" />
|
||||
{formatUptime(node.xray_uptime)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-dark-500">/ {limit > 0 ? formatBytes(limit) : '∞'}</span>
|
||||
</span>
|
||||
{node.xray_uptime > 0 && (
|
||||
<span className="flex items-center gap-1 text-dark-500">
|
||||
<StatUptimeIcon className="h-3 w-3" />
|
||||
{formatUptime(node.xray_uptime)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Live metrics — mobile: 3 fixed rows so wrapping speeds don't reflow;
|
||||
desktop (sm+): the original single wrap row, wide enough not to jump. */}
|
||||
{(ramPct !== null || loadAvg || rx > 0 || tx > 0 || node.versions) && (
|
||||
<>
|
||||
{/* Mobile: processor · traffic · versions */}
|
||||
<div className="mt-2 space-y-1 border-t border-dark-700/60 pt-2 font-mono text-[10.5px] tabular-nums text-dark-500 sm:hidden">
|
||||
{(loadAvg || ramPct !== null) && (
|
||||
<div className="flex items-center gap-3">
|
||||
{loadAvg && (
|
||||
<span className="flex items-center gap-1" title="load average 1 / 5 / 15 min">
|
||||
<CpuIcon className="h-3 w-3 shrink-0 text-dark-500" />
|
||||
{loadAvg}
|
||||
</span>
|
||||
)}
|
||||
{ramPct !== null && (
|
||||
<span className="flex items-center gap-1.5" title="RAM">
|
||||
<MemoryIcon className="h-3 w-3 shrink-0 text-dark-500" />
|
||||
<span className={ramColorClass}>{ramPct}%</span>
|
||||
<span className="h-1 w-10 overflow-hidden rounded-full bg-dark-700">
|
||||
<span
|
||||
className="block h-full rounded-full bg-dark-400"
|
||||
style={{ width: `${ramPct}%` }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(rx > 0 || tx > 0) && (
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<DownloadIcon className="h-3 w-3 shrink-0 text-success-400/70" />
|
||||
{formatSpeed(rx)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<UploadIcon className="h-3 w-3 shrink-0 text-accent-400/70" />
|
||||
{formatSpeed(tx)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(node.versions?.node || node.versions?.xray) && (
|
||||
<div className="flex items-center gap-3 text-dark-600">
|
||||
{node.versions?.node && (
|
||||
<span className="flex items-center gap-1" title="remnanode">
|
||||
<RemnawaveIcon className="h-3 w-3 shrink-0" />
|
||||
{node.versions.node}
|
||||
</span>
|
||||
)}
|
||||
{node.versions?.xray && (
|
||||
<span className="flex items-center gap-1" title="xray core">
|
||||
<XrayIcon className="h-3 w-3 shrink-0" />
|
||||
{node.versions.xray}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: single wrap row (original) */}
|
||||
<div className="mt-2 hidden flex-wrap items-center gap-x-3 gap-y-1 border-t border-dark-700/60 pt-2 font-mono text-[10.5px] tabular-nums text-dark-500 sm:flex">
|
||||
{ramPct !== null && (
|
||||
<span className="flex items-center gap-1.5" title="RAM">
|
||||
<MemoryIcon className="h-3 w-3 text-dark-500" />
|
||||
<span className={ramColorClass}>{ramPct}%</span>
|
||||
<span className="h-1 w-10 overflow-hidden rounded-full bg-dark-700">
|
||||
<span
|
||||
className="block h-full rounded-full bg-dark-400"
|
||||
style={{ width: `${ramPct}%` }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{loadAvg && (
|
||||
<span className="flex items-center gap-1" title="load average 1 / 5 / 15 min">
|
||||
<CpuIcon className="h-3 w-3 text-dark-500" />
|
||||
{loadAvg}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<DownloadIcon className="h-3 w-3 text-success-400/70" />
|
||||
{formatSpeed(rx)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<UploadIcon className="h-3 w-3 text-accent-400/70" />
|
||||
{formatSpeed(tx)}
|
||||
</span>
|
||||
</span>
|
||||
{(node.versions?.node || node.versions?.xray) && (
|
||||
<span className="ml-auto flex items-center gap-2.5 text-dark-600">
|
||||
{node.versions?.node && (
|
||||
<span className="flex items-center gap-1" title="remnanode">
|
||||
<RemnawaveIcon className="h-3 w-3" />
|
||||
{node.versions.node}
|
||||
</span>
|
||||
)}
|
||||
{node.versions?.xray && (
|
||||
<span className="flex items-center gap-1" title="xray core">
|
||||
<XrayIcon className="h-3 w-3" />
|
||||
{node.versions.xray}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Per-node traffic accordion (merged from the former Traffic tab) */}
|
||||
{expanded && hasBreakdown && (
|
||||
<div
|
||||
className="mt-3 space-y-3 border-t border-dark-700/60 pt-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{inbounds.length > 0 && (
|
||||
<NodeTrafficBreakdown
|
||||
title={t('admin.remnawave.traffic.inbounds', 'Inbounds')}
|
||||
items={inbounds}
|
||||
/>
|
||||
)}
|
||||
{outbounds.length > 0 && (
|
||||
<NodeTrafficBreakdown
|
||||
title={t('admin.remnawave.traffic.outbounds', 'Outbounds')}
|
||||
items={outbounds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live metrics — mobile: 3 fixed rows so wrapping speeds don't reflow;
|
||||
desktop (sm+): the original single wrap row, wide enough not to jump. */}
|
||||
{(ramPct !== null || loadAvg || rx > 0 || tx > 0 || node.versions) && (
|
||||
<>
|
||||
{/* Mobile: processor · traffic · versions */}
|
||||
<div className="mt-2 space-y-1 border-t border-dark-700/60 pt-2 font-mono text-[10.5px] tabular-nums text-dark-500 sm:hidden">
|
||||
{(loadAvg || ramPct !== null) && (
|
||||
<div className="flex items-center gap-3">
|
||||
{loadAvg && (
|
||||
<span className="flex items-center gap-1" title="load average 1 / 5 / 15 min">
|
||||
<CpuIcon className="h-3 w-3 shrink-0 text-dark-500" />
|
||||
{loadAvg}
|
||||
</span>
|
||||
)}
|
||||
{ramPct !== null && (
|
||||
<span className="flex items-center gap-1.5" title="RAM">
|
||||
<MemoryIcon className="h-3 w-3 shrink-0 text-dark-500" />
|
||||
<span className={ramColorClass}>{ramPct}%</span>
|
||||
<span className="h-1 w-10 overflow-hidden rounded-full bg-dark-700">
|
||||
<span
|
||||
className="block h-full rounded-full bg-dark-400"
|
||||
style={{ width: `${ramPct}%` }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(rx > 0 || tx > 0) && (
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<DownloadIcon className="h-3 w-3 shrink-0 text-success-400/70" />
|
||||
{formatSpeed(rx)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<UploadIcon className="h-3 w-3 shrink-0 text-accent-400/70" />
|
||||
{formatSpeed(tx)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(node.versions?.node || node.versions?.xray) && (
|
||||
<div className="flex items-center gap-3 text-dark-600">
|
||||
{node.versions?.node && (
|
||||
<span className="flex items-center gap-1" title="remnanode">
|
||||
<RemnawaveIcon className="h-3 w-3 shrink-0" />
|
||||
{node.versions.node}
|
||||
</span>
|
||||
)}
|
||||
{node.versions?.xray && (
|
||||
<span className="flex items-center gap-1" title="xray core">
|
||||
<XrayIcon className="h-3 w-3 shrink-0" />
|
||||
{node.versions.xray}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: single wrap row (original) */}
|
||||
<div className="mt-2 hidden flex-wrap items-center gap-x-3 gap-y-1 border-t border-dark-700/60 pt-2 font-mono text-[10.5px] tabular-nums text-dark-500 sm:flex">
|
||||
{ramPct !== null && (
|
||||
<span className="flex items-center gap-1.5" title="RAM">
|
||||
<MemoryIcon className="h-3 w-3 text-dark-500" />
|
||||
<span className={ramColorClass}>{ramPct}%</span>
|
||||
<span className="h-1 w-10 overflow-hidden rounded-full bg-dark-700">
|
||||
<span
|
||||
className="block h-full rounded-full bg-dark-400"
|
||||
style={{ width: `${ramPct}%` }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{loadAvg && (
|
||||
<span className="flex items-center gap-1" title="load average 1 / 5 / 15 min">
|
||||
<CpuIcon className="h-3 w-3 text-dark-500" />
|
||||
{loadAvg}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-0.5">
|
||||
<DownloadIcon className="h-3 w-3 text-success-400/70" />
|
||||
{formatSpeed(rx)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<UploadIcon className="h-3 w-3 text-accent-400/70" />
|
||||
{formatSpeed(tx)}
|
||||
</span>
|
||||
</span>
|
||||
{(node.versions?.node || node.versions?.xray) && (
|
||||
<span className="ml-auto flex items-center gap-2.5 text-dark-600">
|
||||
{node.versions?.node && (
|
||||
<span className="flex items-center gap-1" title="remnanode">
|
||||
<RemnawaveIcon className="h-3 w-3" />
|
||||
{node.versions.node}
|
||||
</span>
|
||||
)}
|
||||
{node.versions?.xray && (
|
||||
<span className="flex items-center gap-1" title="xray core">
|
||||
<XrayIcon className="h-3 w-3" />
|
||||
{node.versions.xray}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Per-node traffic accordion (merged from the former Traffic tab) */}
|
||||
{expanded && hasBreakdown && (
|
||||
<div
|
||||
className="mt-3 space-y-3 border-t border-dark-700/60 pt-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{inbounds.length > 0 && (
|
||||
<NodeTrafficBreakdown
|
||||
title={t('admin.remnawave.traffic.inbounds', 'Inbounds')}
|
||||
items={inbounds}
|
||||
/>
|
||||
)}
|
||||
{outbounds.length > 0 && (
|
||||
<NodeTrafficBreakdown
|
||||
title={t('admin.remnawave.traffic.outbounds', 'Outbounds')}
|
||||
items={outbounds}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{geoCheckOpen && <GeoCheckModal node={node} onClose={() => setGeoCheckOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,16 @@ import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
|
||||
import { adminSettingsApi, type SettingDefinition } from '../api/adminSettings';
|
||||
import { themeColorsApi } from '../api/themeColors';
|
||||
import { useFavoriteSettings } from '../hooks/useFavoriteSettings';
|
||||
import { SETTINGS_TREE, findTreeLocation, formatSettingKey } from '../components/admin';
|
||||
import {
|
||||
OTHER_CATEGORIES,
|
||||
SETTINGS_TREE,
|
||||
findTreeLocation,
|
||||
formatSettingKey,
|
||||
getMappedCategoryKeys,
|
||||
} from '../components/admin';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
|
||||
import { BrandingTab } from '../components/admin/BrandingTab';
|
||||
@@ -75,10 +81,18 @@ export default function AdminSettings() {
|
||||
if (!activeTreeInfo || !allSettings || !Array.isArray(allSettings)) return [];
|
||||
|
||||
const categoryKeys = activeTreeInfo.child.categories;
|
||||
// Пункт-сборник показывает всё, чему не нашлось места в дереве: категории на
|
||||
// бэкенде выводятся из имени ключа, поэтому новая настройка легко создаёт
|
||||
// категорию, которой здесь нет — раньше такие настройки просто исчезали.
|
||||
const isOtherSection = categoryKeys.includes(OTHER_CATEGORIES);
|
||||
const mappedCategories = isOtherSection ? getMappedCategoryKeys() : null;
|
||||
const categoryMap = new Map<string, SettingDefinition[]>();
|
||||
|
||||
for (const setting of allSettings) {
|
||||
if (categoryKeys.includes(setting.category.key)) {
|
||||
const belongsHere = mappedCategories
|
||||
? !mappedCategories.has(setting.category.key)
|
||||
: categoryKeys.includes(setting.category.key);
|
||||
if (belongsHere) {
|
||||
// Hide tariff-dependent settings when not in tariffs mode
|
||||
if (!isTariffsMode && TARIFF_MODE_SETTINGS.includes(setting.key)) continue;
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ export default function AdminTariffCreate() {
|
||||
const [selectedExternalSquad, setSelectedExternalSquad] = useState<string | null>(null);
|
||||
const [selectedPromoGroups, setSelectedPromoGroups] = useState<number[]>([]);
|
||||
const [dailyPriceKopeks, setDailyPriceKopeks] = useState<number | ''>(0);
|
||||
const [lavaProductId, setLavaProductId] = useState('');
|
||||
|
||||
// Traffic topup
|
||||
const [trafficTopupEnabled, setTrafficTopupEnabled] = useState(false);
|
||||
@@ -122,6 +123,7 @@ export default function AdminTariffCreate() {
|
||||
data.promo_groups?.filter((pg) => pg.is_selected).map((pg) => pg.id) || [],
|
||||
);
|
||||
setDailyPriceKopeks(data.daily_price_kopeks || 0);
|
||||
setLavaProductId(data.lava_product_id || '');
|
||||
setTrafficTopupEnabled(data.traffic_topup_enabled || false);
|
||||
setMaxTopupTrafficGb(data.max_topup_traffic_gb || 0);
|
||||
setTrafficTopupPackages(data.traffic_topup_packages || {});
|
||||
@@ -176,6 +178,8 @@ export default function AdminTariffCreate() {
|
||||
max_topup_traffic_gb: toNumber(maxTopupTrafficGb),
|
||||
is_daily: isDaily,
|
||||
daily_price_kopeks: isDaily ? toNumber(dailyPriceKopeks) : 0,
|
||||
// Пустая строка отвязывает тариф от продукта Lava
|
||||
lava_product_id: lavaProductId.trim(),
|
||||
traffic_reset_mode: trafficResetMode,
|
||||
};
|
||||
|
||||
@@ -460,6 +464,25 @@ export default function AdminTariffCreate() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lava recurrent product */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="tariff-lava-product"
|
||||
className="mb-2 block text-sm font-medium text-dark-300"
|
||||
>
|
||||
{t('admin.tariffs.lavaProductLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="tariff-lava-product"
|
||||
type="text"
|
||||
value={lavaProductId}
|
||||
onChange={(e) => setLavaProductId(e.target.value)}
|
||||
className="input w-full"
|
||||
placeholder="6be21df9-0bcd-44ac-9c2c-3be7bc94decc"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-dark-500">{t('admin.tariffs.lavaProductDesc')}</p>
|
||||
</div>
|
||||
|
||||
{/* Traffic Limit */}
|
||||
<div>
|
||||
<label
|
||||
|
||||
@@ -551,7 +551,7 @@ export default function AdminTickets() {
|
||||
</div>
|
||||
{msg.message_text && (
|
||||
<p
|
||||
className="whitespace-pre-wrap text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
|
||||
className="whitespace-pre-wrap break-words text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
|
||||
dangerouslySetInnerHTML={{ __html: linkifyText(msg.message_text) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ActivityTab } from '../components/admin/userDetail/ActivityTab';
|
||||
import { TicketsTab } from '../components/admin/userDetail/TicketsTab';
|
||||
import { InfoTab } from '../components/admin/userDetail/InfoTab';
|
||||
import { SubscriptionTab } from '../components/admin/userDetail/SubscriptionTab';
|
||||
import { getApiErrorMessage } from '../utils/api-error';
|
||||
import { toNumber } from '../utils/inputHelpers';
|
||||
import { usePermissionStore } from '../store/permissions';
|
||||
|
||||
@@ -662,6 +663,28 @@ export default function AdminUserDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSubscription = async () => {
|
||||
if (!userId || !selectedSub) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
// Активную платную подписку сервер по умолчанию бережёт — админ уже
|
||||
// подтвердил намерение кнопкой, поэтому просим удалить именно её.
|
||||
const force = Boolean(selectedSub.is_active) && !selectedSub.is_trial;
|
||||
await adminUsersApi.deleteSubscription(userId, selectedSub.id, force);
|
||||
notify.success(t('admin.users.detail.subscription.deleted'), t('common.success'));
|
||||
setSubscriptionDetailView(false);
|
||||
await loadUser();
|
||||
} catch (err) {
|
||||
// Отказы тут осмысленные и действенные: открытый временный доступ
|
||||
// (409, «сначала заверши или восстанови grace»), активная платная без
|
||||
// force (409), подписки нет (404). Общее «Ошибка» оставило бы админа
|
||||
// гадать, почему кнопка не сработала, — показываем текст сервера.
|
||||
notify.error(getApiErrorMessage(err, t('admin.users.userActions.error')), t('common.error'));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisableUser = async () => {
|
||||
if (!userId) return;
|
||||
setActionLoading(true);
|
||||
@@ -874,6 +897,7 @@ export default function AdminUserDetail() {
|
||||
userSubscriptions={userSubscriptions}
|
||||
selectedSub={selectedSub}
|
||||
onCancelSbpRecurring={handleCancelSbpRecurring}
|
||||
onDeleteSubscription={handleDeleteSubscription}
|
||||
activeSubscriptionId={activeSubscriptionId}
|
||||
onActiveSubscriptionChange={setActiveSubscriptionId}
|
||||
subscriptionDetailView={subscriptionDetailView}
|
||||
|
||||
@@ -288,6 +288,7 @@ export default function AdminUsers() {
|
||||
<option value="balance">{t('admin.users.filters.byBalance')}</option>
|
||||
<option value="last_activity">{t('admin.users.filters.byActivity')}</option>
|
||||
<option value="total_spent">{t('admin.users.filters.bySpent')}</option>
|
||||
<option value="subscription_end_date">{t('admin.users.filters.byExpiry')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQueries, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
@@ -20,6 +20,7 @@ import { giftApi } from '../api/gift';
|
||||
import { promoApi } from '../api/promo';
|
||||
import PendingGiftCard from '../components/dashboard/PendingGiftCard';
|
||||
import SubscriptionListCard from '../components/subscription/SubscriptionListCard';
|
||||
import { DeviceLimitSheet } from '../components/subscription/DeviceLimitSheet';
|
||||
import { API } from '../config/constants';
|
||||
import { ChevronRightIcon, StarIcon } from '@/components/icons';
|
||||
|
||||
@@ -79,6 +80,35 @@ export default function Dashboard() {
|
||||
staleTime: API.BALANCE_STALE_TIME_MS,
|
||||
});
|
||||
|
||||
// Плитка «Подключить устройство» на главной живёт в МУЛЬТИТАРИФНОЙ ветке, а
|
||||
// запрос выше там выключен: он привязан к одиночной подписке (`subscription`
|
||||
// в мультитарифе всегда null). Без отдельного запроса счётчик плитки всегда
|
||||
// показывал бы «0 из N», а лимит устройств не срабатывал бы никогда — то
|
||||
// есть ровно то, ради чего плитку и добавили, не работало бы.
|
||||
// Ключ ['devices', id] — тот же, что на странице подписки, так что кэш общий.
|
||||
// Карточки подписок на главной показывают, сколько устройств подключено, и
|
||||
// дают подключить ещё. Число устройств живёт в панели, поэтому запрос идёт
|
||||
// на каждую показанную подписку; ключ ['devices', id] тот же, что на
|
||||
// странице подписки, так что кэш общий и переход туда не стоит сети.
|
||||
const visibleSubscriptions = useMemo(
|
||||
() => multiSubData?.subscriptions?.slice(0, 3) ?? [],
|
||||
[multiSubData],
|
||||
);
|
||||
|
||||
const deviceQueries = useQueries({
|
||||
queries: visibleSubscriptions.map((sub) => ({
|
||||
queryKey: ['devices', sub.id],
|
||||
queryFn: () => subscriptionApi.getDevices(sub.id),
|
||||
staleTime: API.BALANCE_STALE_TIME_MS,
|
||||
})),
|
||||
});
|
||||
|
||||
// Подписка, у которой разбираем исчерпанный лимит устройств.
|
||||
const [deviceLimitSubId, setDeviceLimitSubId] = useState<number | null>(null);
|
||||
const deviceLimitSub = visibleSubscriptions.find((s) => s.id === deviceLimitSubId) ?? null;
|
||||
const deviceLimitDevices =
|
||||
deviceQueries[visibleSubscriptions.findIndex((s) => s.id === deviceLimitSubId)]?.data;
|
||||
|
||||
const { data: referralInfo, isLoading: refLoading } = useQuery({
|
||||
queryKey: ['referral-info'],
|
||||
queryFn: referralApi.getReferralInfo,
|
||||
@@ -294,11 +324,16 @@ export default function Dashboard() {
|
||||
{t('dashboard.manageAll', 'Управление')} →
|
||||
</Link>
|
||||
</div>
|
||||
{multiSubData.subscriptions.slice(0, 3).map((sub) => (
|
||||
{visibleSubscriptions.map((sub, index) => (
|
||||
<SubscriptionListCard
|
||||
key={sub.id}
|
||||
subscription={sub}
|
||||
onClick={() => navigate(`/subscriptions/${sub.id}`)}
|
||||
connect={{
|
||||
connectedDevices: deviceQueries[index]?.data?.total,
|
||||
onConnect: () => navigate(`/connection?sub=${sub.id}`),
|
||||
onManage: () => setDeviceLimitSubId(sub.id),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{multiSubData.subscriptions.length > 3 && (
|
||||
@@ -426,6 +461,22 @@ export default function Dashboard() {
|
||||
onSkip={handleOnboardingComplete}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deviceLimitSub && (
|
||||
<DeviceLimitSheet
|
||||
isOpen
|
||||
onClose={() => setDeviceLimitSubId(null)}
|
||||
subscriptionId={deviceLimitSub.id}
|
||||
subscriptionName={deviceLimitSub.tariff_name || t('subscription.defaultName', 'Подписка')}
|
||||
deviceLimit={deviceLimitSub.device_limit}
|
||||
isTrial={deviceLimitSub.is_trial}
|
||||
devices={deviceLimitDevices?.devices ?? []}
|
||||
onOpenSubscription={() => {
|
||||
setDeviceLimitSubId(null);
|
||||
navigate(`/subscriptions/${deviceLimitSub.id}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useSearchParams, useNavigate } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion } from 'framer-motion';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { giftApi } from '../api/gift';
|
||||
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
|
||||
import { Spinner } from '@/components/ui/Spinner';
|
||||
@@ -121,6 +122,14 @@ function CodeOnlySuccessState({
|
||||
<p className="select-all font-mono text-lg font-bold text-accent-400">{giftCode}</p>
|
||||
</div>
|
||||
|
||||
{/* QR — получателю проще отсканировать, чем копировать ссылку.
|
||||
Кодируем bot-ссылку, если она есть: активация в боте — основной путь,
|
||||
иначе ссылку кабинета. */}
|
||||
<div className="flex w-full flex-col items-center gap-2 rounded-xl border border-dark-700/30 bg-white p-4">
|
||||
<QRCodeSVG value={botLink ?? cabinetLink} size={180} level="M" includeMargin={false} />
|
||||
</div>
|
||||
<p className="-mt-3 text-xs text-dark-400">{t('gift.qrHint', 'Scan to activate the gift')}</p>
|
||||
|
||||
{/* Share message preview */}
|
||||
<div className="w-full rounded-xl border border-dark-700/30 bg-dark-800/40 p-4 text-left">
|
||||
<p className="mb-3 text-sm font-medium text-dark-100">
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { uiLocale } from '@/utils/uiLocale';
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { giftApi } from '../api/gift';
|
||||
import {
|
||||
canUseTelegramScanner,
|
||||
loadHtml5Qrcode,
|
||||
parseGiftCode,
|
||||
scanWithTelegram,
|
||||
type Html5QrcodeInstance,
|
||||
} from '@/utils/qrScanner';
|
||||
import { brandingApi, type TelegramWidgetConfig } from '../api/branding';
|
||||
import type {
|
||||
GiftConfig,
|
||||
@@ -743,6 +750,82 @@ function ActivateTabContent({ initialCode }: { initialCode?: string | null }) {
|
||||
if (initialCode) setCode(initialCode);
|
||||
}, [initialCode]);
|
||||
const [activateError, setActivateError] = useState<string | null>(null);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const scannerRef = useRef<Html5QrcodeInstance | null>(null);
|
||||
|
||||
const stopScan = useCallback(() => {
|
||||
if (scannerRef.current) {
|
||||
scannerRef.current
|
||||
.stop()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
scannerRef.current?.clear();
|
||||
scannerRef.current = null;
|
||||
});
|
||||
}
|
||||
setScanning(false);
|
||||
}, []);
|
||||
|
||||
// Веб-сканер держит камеру: гасим его при уходе с вкладки/размонтировании,
|
||||
// иначе индикатор камеры остаётся гореть.
|
||||
useEffect(() => stopScan, [stopScan]);
|
||||
|
||||
const applyScannedCode = useCallback(
|
||||
(decoded: string) => {
|
||||
const parsed = parseGiftCode(decoded);
|
||||
if (!parsed) {
|
||||
setActivateError(t('gift.scanNotRecognized'));
|
||||
return;
|
||||
}
|
||||
setCode(parsed);
|
||||
setActivateError(null);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const handleScan = useCallback(async () => {
|
||||
setActivateError(null);
|
||||
|
||||
if (canUseTelegramScanner()) {
|
||||
try {
|
||||
const decoded = await scanWithTelegram(
|
||||
t('gift.scanDescription'),
|
||||
(v) => parseGiftCode(v) !== null,
|
||||
);
|
||||
if (decoded) applyScannedCode(decoded);
|
||||
} catch {
|
||||
setActivateError(t('gift.scanError'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const Html5Qrcode = await loadHtml5Qrcode();
|
||||
if (!Html5Qrcode) {
|
||||
setActivateError(t('gift.scanNoCamera'));
|
||||
return;
|
||||
}
|
||||
|
||||
setScanning(true);
|
||||
const scanner = new Html5Qrcode('gift-qr-reader');
|
||||
scannerRef.current = scanner;
|
||||
const config = { fps: 10, qrbox: { width: 220, height: 220 } };
|
||||
const onDecoded = (decoded: string) => {
|
||||
if (parseGiftCode(decoded) === null) return;
|
||||
stopScan();
|
||||
applyScannedCode(decoded);
|
||||
};
|
||||
try {
|
||||
await scanner.start({ facingMode: 'environment' }, config, onDecoded, () => undefined);
|
||||
} catch {
|
||||
try {
|
||||
await scanner.start({ facingMode: 'user' }, config, onDecoded, () => undefined);
|
||||
} catch {
|
||||
setActivateError(t('gift.scanNoCamera'));
|
||||
scannerRef.current = null;
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
}, [applyScannedCode, stopScan, t]);
|
||||
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: (giftCode: string) => giftApi.activateGiftCode(giftCode),
|
||||
@@ -810,6 +893,32 @@ function ActivateTabContent({ initialCode }: { initialCode?: string | null }) {
|
||||
className="w-full rounded-2xl border border-dark-700/50 bg-dark-800/50 px-6 py-4 text-center font-mono text-sm text-dark-50 placeholder-dark-500 outline-none transition-colors focus:border-accent-500/50 focus:ring-1 focus:ring-accent-500/25"
|
||||
aria-label={t('gift.activateTitle')}
|
||||
/>
|
||||
|
||||
{/* Скан QR: в Telegram — нативный сканер (в WebView камера через
|
||||
getUserMedia работает ненадёжно), в вебе — html5-qrcode. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
className="mt-3 w-full rounded-2xl border border-dark-700/50 px-6 py-3 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-800/50 disabled:opacity-50"
|
||||
>
|
||||
{scanning ? t('gift.scanInProgress') : t('gift.scanButton')}
|
||||
</button>
|
||||
|
||||
{/* Контейнер веб-сканера: html5-qrcode рендерит превью камеры внутрь */}
|
||||
<div
|
||||
id="gift-qr-reader"
|
||||
className={cn('mt-3 overflow-hidden rounded-2xl', !scanning && 'hidden')}
|
||||
/>
|
||||
{scanning && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopScan}
|
||||
className="mt-2 w-full rounded-2xl border border-dark-700/50 px-6 py-2 text-xs text-dark-400 transition-colors hover:bg-dark-800/50"
|
||||
>
|
||||
{t('gift.scanCancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
|
||||
@@ -26,9 +26,12 @@ import { saveOAuthState } from '../utils/oauth';
|
||||
import { getPendingReferralCode } from '../utils/referral';
|
||||
import { UsersIcon, EmailIcon, RefreshIcon, ChevronDownIcon } from '@/components/icons';
|
||||
import LegalFooter from '../components/LegalFooter';
|
||||
import LegalConsent from '../components/LegalConsent';
|
||||
import { infoApi } from '../api/info';
|
||||
import type { LegalConsentConfig } from '../types';
|
||||
|
||||
export default function Login() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const {
|
||||
@@ -69,6 +72,70 @@ export default function Login() {
|
||||
const [forgotPasswordError, setForgotPasswordError] = useState('');
|
||||
const [showEmailForm, setShowEmailForm] = useState(true);
|
||||
|
||||
// Гейт согласия с офертой/политикой для НОВОГО пользователя. Конфиг публичный:
|
||||
// нужен до авторизации, чтобы нарисовать чекбоксы ещё на экране входа.
|
||||
const { data: legalConsent } = useQuery<LegalConsentConfig>({
|
||||
queryKey: ['legal-consent-config', i18n.language],
|
||||
queryFn: () => infoApi.getLegalConsentConfig(i18n.language),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
const consentDocuments = useMemo(() => legalConsent?.documents ?? [], [legalConsent]);
|
||||
const [acceptedDocuments, setAcceptedDocuments] = useState<Record<string, boolean>>({});
|
||||
// Telegram-вход происходит сам собой, поэтому чекбоксы показываем только когда
|
||||
// бэк ответил 428: пользователь новый и без согласия аккаунт не создастся.
|
||||
// Замыкание помнит, какой именно вход повторить после простановки галочек.
|
||||
const [pendingConsentRetry, setPendingConsentRetry] = useState<
|
||||
((accepted: string[]) => Promise<void>) | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!legalConsent?.prechecked || consentDocuments.length === 0) return;
|
||||
setAcceptedDocuments((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const document of consentDocuments) {
|
||||
if (next[document] === undefined) next[document] = true;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [legalConsent?.prechecked, consentDocuments]);
|
||||
|
||||
const acceptedDocumentKeys = useMemo(
|
||||
() => consentDocuments.filter((document) => acceptedDocuments[document]),
|
||||
[consentDocuments, acceptedDocuments],
|
||||
);
|
||||
const allDocumentsAccepted =
|
||||
consentDocuments.length === 0 || acceptedDocumentKeys.length === consentDocuments.length;
|
||||
|
||||
const toggleDocument = useCallback((document: string, value: boolean) => {
|
||||
setAcceptedDocuments((prev) => ({ ...prev, [document]: value }));
|
||||
}, []);
|
||||
|
||||
// 428 = бэк требует согласие. Запоминаем, что повторить, и рисуем чекбоксы.
|
||||
const captureConsentRequirement = useCallback(
|
||||
(err: unknown, retry: (accepted: string[]) => Promise<void>): boolean => {
|
||||
const error = err as { response?: { status?: number; data?: { detail?: unknown } } };
|
||||
if (error.response?.status !== 428) return false;
|
||||
|
||||
const detail = error.response?.data?.detail as
|
||||
| { documents?: string[]; prechecked?: boolean }
|
||||
| undefined;
|
||||
if (detail?.documents?.length) {
|
||||
const documents = detail.documents;
|
||||
setAcceptedDocuments((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const document of documents) {
|
||||
if (next[document] === undefined) next[document] = Boolean(detail.prechecked);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
setPendingConsentRetry(() => retry);
|
||||
return true;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Telegram safe area insets
|
||||
const { safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
|
||||
const safeTop = Math.max(safeAreaInset.top, contentSafeAreaInset.top);
|
||||
@@ -197,6 +264,16 @@ export default function Login() {
|
||||
if (import.meta.env.DEV)
|
||||
console.warn(`Telegram auth attempt ${attempt + 1} failed:`, status, detail);
|
||||
|
||||
// Не ошибка входа, а недостающее согласие: показываем чекбоксы.
|
||||
const needsConsent = captureConsentRequirement(err, async (accepted) => {
|
||||
await loginWithTelegram(initData, accepted);
|
||||
navigate(getReturnUrl(), { replace: true });
|
||||
});
|
||||
if (needsConsent) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 401 && attempt < MAX_RETRIES) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
continue;
|
||||
@@ -211,7 +288,7 @@ export default function Login() {
|
||||
};
|
||||
|
||||
tryTelegramAuth();
|
||||
}, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl]);
|
||||
}, [isAuthInitializing, loginWithTelegram, navigate, t, getReturnUrl, captureConsentRequirement]);
|
||||
|
||||
const handleRetryTelegramAuth = () => {
|
||||
// Clear ALL cached auth state to prevent stale token/initData loops
|
||||
@@ -264,6 +341,7 @@ export default function Login() {
|
||||
password,
|
||||
firstName || undefined,
|
||||
referralCode || undefined,
|
||||
acceptedDocumentKeys,
|
||||
);
|
||||
// Show "check your email" screen
|
||||
setRegisteredEmail(result.email);
|
||||
@@ -273,6 +351,24 @@ export default function Login() {
|
||||
const status = error.response?.status;
|
||||
const detail = getApiErrorMessage(err, '');
|
||||
|
||||
// Конфиг чекбоксов мог протухнуть (админ включил гейт между загрузкой страницы
|
||||
// и отправкой формы) — показываем недостающие галочки вместо сырой ошибки.
|
||||
const needsConsent = captureConsentRequirement(err, async (accepted) => {
|
||||
const retried = await registerWithEmail(
|
||||
email,
|
||||
password,
|
||||
firstName || undefined,
|
||||
referralCode || undefined,
|
||||
accepted,
|
||||
);
|
||||
setPendingConsentRetry(null);
|
||||
setRegisteredEmail(retried.email);
|
||||
});
|
||||
if (needsConsent) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === 400 && detail.includes('already registered')) {
|
||||
setError(t('auth.emailAlreadyRegistered', 'This email is already registered'));
|
||||
} else if (status === 401 || status === 403) {
|
||||
@@ -376,8 +472,56 @@ export default function Login() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Check Email Screen */}
|
||||
{registeredEmail ? (
|
||||
{/* Экран согласия: бэк ответил 428 на автоматический Telegram-вход */}
|
||||
{pendingConsentRetry ? (
|
||||
<div className="card">
|
||||
<h2 className="mb-2 text-lg font-bold text-dark-50">
|
||||
{t('auth.legalConsentTitle', 'Ещё один шаг')}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
{t(
|
||||
'auth.legalConsentSubtitle',
|
||||
'Чтобы создать аккаунт, подтвердите, что ознакомились с документами.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
<LegalConsent
|
||||
documents={consentDocuments}
|
||||
accepted={acceptedDocuments}
|
||||
onChange={toggleDocument}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="mt-4 text-sm text-error-400" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary mt-5 w-full"
|
||||
disabled={!allDocumentsAccepted || isLoading}
|
||||
onClick={async () => {
|
||||
setError('');
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await pendingConsentRetry(acceptedDocumentKeys);
|
||||
setPendingConsentRetry(null);
|
||||
} catch (err) {
|
||||
setError(getApiErrorMessage(err, t('common.error')));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading
|
||||
? t('common.loading', 'Загрузка...')
|
||||
: t('auth.legalConsentContinue', 'Продолжить')}
|
||||
</button>
|
||||
</div>
|
||||
) : /* Check Email Screen */
|
||||
registeredEmail ? (
|
||||
<div className="card text-center">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-success-500/20">
|
||||
<EmailIcon className="h-7 w-7 text-success-400" />
|
||||
@@ -696,9 +840,21 @@ export default function Login() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authMode === 'register' && (
|
||||
<LegalConsent
|
||||
documents={consentDocuments}
|
||||
accepted={acceptedDocuments}
|
||||
onChange={toggleDocument}
|
||||
disabled={isLoading}
|
||||
className="pt-1"
|
||||
/>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
disabled={
|
||||
isLoading || (authMode === 'register' && !allDocumentsAccepted)
|
||||
}
|
||||
className="btn-primary w-full py-2.5"
|
||||
>
|
||||
{isLoading ? (
|
||||
|
||||
@@ -29,6 +29,8 @@ import { CheckCircleIcon, CheckIcon, DevicesIcon, DownloadIcon } from '@/compone
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
import { cn } from '../lib/utils';
|
||||
import { getApiErrorMessage } from '../utils/api-error';
|
||||
import { getPendingCampaignSlug } from '../utils/campaign';
|
||||
import { readContactPrefill, stripContactFromUrl } from '../utils/contactPrefill';
|
||||
import { formatPrice } from '../utils/format';
|
||||
import { setFavicon, letterFaviconDataUri, roundedFaviconDataUri } from '../utils/favicon';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
@@ -868,13 +870,12 @@ export default function QuickPurchase() {
|
||||
const [selectedTariffId, setSelectedTariffId] = useState<number | null>(null);
|
||||
const [selectedPeriodDays, setSelectedPeriodDays] = useState<number | null>(null);
|
||||
const contactKey = `lp_contact_${slug ?? ''}`;
|
||||
const [contactValue, setContactValue] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(contactKey) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
const [contactValue, setContactValue] = useState(() => readContactPrefill(contactKey));
|
||||
// Контакт уже в состоянии — вычищаем его из адресной строки, чтобы личный
|
||||
// email не уехал в Метрику, Referer и историю браузера.
|
||||
useEffect(() => {
|
||||
stripContactFromUrl();
|
||||
}, []);
|
||||
const [isGift, setIsGift] = useState(false);
|
||||
const [giftRecipient, setGiftRecipient] = useState('');
|
||||
const [giftMessage, setGiftMessage] = useState('');
|
||||
@@ -1085,6 +1086,12 @@ export default function QuickPurchase() {
|
||||
const subid = sessionStorage.getItem('landing_subid');
|
||||
if (subid) (data as unknown as Record<string, unknown>).subid = subid;
|
||||
|
||||
// Слаг рекламной кампании захватил captureCampaignFromUrl() при заходе по
|
||||
// рекламной ссылке. Читаем БЕЗ потребления: гость может позже войти в
|
||||
// кабинет, и там привязка должна остаться возможной.
|
||||
const campaignSlug = getPendingCampaignSlug();
|
||||
if (campaignSlug) data.campaign_slug = campaignSlug;
|
||||
|
||||
// Fire landing-specific click goal
|
||||
if (config?.analytics_click_enabled && config?.analytics_click_goal) {
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Navigate, useNavigate, useParams } from 'react-router';
|
||||
import { subscriptionApi } from '../api/subscription';
|
||||
import { useTheme } from '../hooks/useTheme';
|
||||
import { getGlassColors } from '../utils/glassTheme';
|
||||
import { getMonthlyPriceKopeks } from '../utils/pricing';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { useHaptic } from '../platform';
|
||||
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
||||
@@ -143,8 +144,7 @@ export default function RenewSubscription() {
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedPeriod === option.period_days;
|
||||
const canAfford = balanceKopeks >= option.price_kopeks;
|
||||
const months = Math.max(1, Math.round(option.period_days / 30));
|
||||
const perMonth = option.price_kopeks / months;
|
||||
const perMonth = getMonthlyPriceKopeks(option.price_kopeks, option.period_days);
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -167,7 +167,7 @@ export default function RenewSubscription() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-base font-semibold" style={{ color: g.text }}>
|
||||
{option.period_days} {t('common.units.days', 'дней')}
|
||||
{option.period_days} {t('subscription.days', 'дней')}
|
||||
</span>
|
||||
{option.discount_percent > 0 && (
|
||||
<span className="ml-2 rounded-full bg-success-400/15 px-2 py-0.5 text-[10px] font-semibold text-success-400">
|
||||
@@ -181,10 +181,10 @@ export default function RenewSubscription() {
|
||||
? t('subscription.free', 'Бесплатно')
|
||||
: `${formatAmount(option.price_kopeks / 100)} ${currencySymbol}`}
|
||||
</div>
|
||||
{months > 1 && (
|
||||
{perMonth !== null && (
|
||||
<div className="text-[11px]" style={{ color: g.textSecondary }}>
|
||||
{formatAmount(perMonth / 100)} {currencySymbol}/
|
||||
{t('common.units.mo', 'мес')}
|
||||
{t('subscription.month', 'мес')}
|
||||
</div>
|
||||
)}
|
||||
{option.original_price_kopeks && (
|
||||
|
||||
@@ -43,6 +43,12 @@ import {
|
||||
sbpUiState,
|
||||
type SbpUiState,
|
||||
} from '../utils/sbpRecurring';
|
||||
import {
|
||||
isLavaFeatureDisabledError,
|
||||
lavaPeriodLabelKey,
|
||||
lavaUiState,
|
||||
type LavaUiState,
|
||||
} from '../utils/lavaRecurring';
|
||||
import Twemoji from 'react-twemoji';
|
||||
import { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet';
|
||||
import { DeviceReductionSheet } from '../components/subscription/sheets/DeviceReductionSheet';
|
||||
@@ -375,6 +381,78 @@ export default function Subscription() {
|
||||
cancelSbpMutation.mutate();
|
||||
};
|
||||
|
||||
// Автопродление Lava — независимый от Platega движок с той же семантикой
|
||||
// состояний. Поллинг раз в 8с, пока привязка PENDING (ждём оплату первого
|
||||
// счёта), чтобы UI сам перешёл в active/past_due.
|
||||
const lavaQuery = useQuery({
|
||||
queryKey: ['lava-recurring', subscriptionId],
|
||||
queryFn: () => subscriptionApi.getLavaRecurring(subscriptionId),
|
||||
enabled: !!subscription && !subscription.is_trial,
|
||||
retry: false,
|
||||
refetchInterval: (query) => (query.state.data?.status === 'PENDING' ? 8000 : false),
|
||||
});
|
||||
const lavaInfo = lavaQuery.data;
|
||||
const lavaFeatureDisabled = isLavaFeatureDisabledError(lavaQuery.error);
|
||||
const lavaUiStateValue: LavaUiState =
|
||||
lavaInfo !== undefined || lavaFeatureDisabled
|
||||
? lavaUiState(lavaInfo, lavaFeatureDisabled)
|
||||
: 'hidden';
|
||||
|
||||
const enableLavaMutation = useMutation({
|
||||
mutationFn: () => subscriptionApi.enableLavaRecurring(subscriptionId),
|
||||
onSuccess: (data) => {
|
||||
if (data.redirect_url) {
|
||||
openPaymentUrl(data.redirect_url, platform, openLink);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['lava-recurring', subscriptionId] });
|
||||
// Бэкенд снимает autopay_enabled при включении рекуррента провайдера.
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data
|
||||
?.detail;
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: typeof detail === 'string' ? detail : t('subscription.lavaRecurring.enableError'),
|
||||
message: '',
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const cancelLavaMutation = useMutation({
|
||||
mutationFn: () => subscriptionApi.cancelLavaRecurring(subscriptionId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lava-recurring', subscriptionId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: t('subscription.lavaRecurring.cancelled'),
|
||||
message: '',
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data
|
||||
?.detail;
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: typeof detail === 'string' ? detail : t('subscription.lavaRecurring.cancelError'),
|
||||
message: '',
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleCancelLava = async () => {
|
||||
const confirmed = await destructiveConfirm(
|
||||
t('subscription.lavaRecurring.confirmCancel'),
|
||||
t('subscription.lavaRecurring.cancel'),
|
||||
);
|
||||
if (!confirmed) return;
|
||||
cancelLavaMutation.mutate();
|
||||
};
|
||||
|
||||
const autopayMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) =>
|
||||
subscriptionApi.updateAutopay(enabled, undefined, subscriptionId),
|
||||
@@ -1280,6 +1358,128 @@ export default function Subscription() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Автопродление Lava ───
|
||||
Независимый от Platega движок: сиблинг того же тоггла, те же
|
||||
состояния. Период задан продуктом в кабинете Lava и приезжает
|
||||
числом дней, поэтому подпись строится из charge_days. */}
|
||||
{!subscription.is_trial && lavaUiStateValue !== 'hidden' && (
|
||||
<div
|
||||
className="mt-3 rounded-[14px] p-3.5"
|
||||
style={{
|
||||
background: g.innerBg,
|
||||
border: `1px solid ${g.innerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-dark-50">
|
||||
{t('subscription.lavaRecurring.title')}
|
||||
</div>
|
||||
|
||||
{lavaUiStateValue === 'off' && (
|
||||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||||
{t('subscription.lavaRecurring.autopayHint')}
|
||||
</div>
|
||||
)}
|
||||
{lavaUiStateValue === 'pending' && (
|
||||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||||
{t('subscription.lavaRecurring.statusPending')}
|
||||
</div>
|
||||
)}
|
||||
{lavaUiStateValue === 'active' && lavaInfo && (
|
||||
<>
|
||||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||||
{(() => {
|
||||
const periodKey = lavaPeriodLabelKey(lavaInfo.charge_days);
|
||||
const amount = formatAmount((lavaInfo.amount_kopeks ?? 0) / 100);
|
||||
return periodKey
|
||||
? t('subscription.lavaRecurring.amountPerPeriod', {
|
||||
amount,
|
||||
period: t(periodKey),
|
||||
})
|
||||
: t('subscription.lavaRecurring.amountPerDays', {
|
||||
amount,
|
||||
days: lavaInfo.charge_days ?? 0,
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
{lavaInfo.next_charge_at && (
|
||||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||||
{t('subscription.lavaRecurring.nextCharge', {
|
||||
date: new Date(lavaInfo.next_charge_at).toLocaleDateString(
|
||||
uiLocale(),
|
||||
{
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
},
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{lavaUiStateValue === 'past_due' && (
|
||||
<div className="mt-0.5 text-[11px] font-medium text-warning-400">
|
||||
{t('subscription.lavaRecurring.statusPastDue')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-2 sm:items-end">
|
||||
{lavaUiStateValue === 'off' && (
|
||||
<button
|
||||
onClick={() => enableLavaMutation.mutate()}
|
||||
disabled={enableLavaMutation.isPending}
|
||||
className="w-full whitespace-nowrap rounded-xl bg-accent-500 px-5 py-2.5 text-sm font-medium text-on-accent transition-opacity disabled:opacity-50 sm:w-auto"
|
||||
>
|
||||
{enableLavaMutation.isPending ? (
|
||||
<span className="mx-auto block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
t('subscription.lavaRecurring.connect')
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{lavaUiStateValue === 'pending' && (
|
||||
<>
|
||||
{lavaInfo?.redirect_url && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (lavaInfo.redirect_url) {
|
||||
openPaymentUrl(lavaInfo.redirect_url, platform, openLink);
|
||||
}
|
||||
}}
|
||||
className="w-full whitespace-nowrap rounded-xl bg-accent-500 px-5 py-2.5 text-sm font-medium text-on-accent transition-opacity sm:w-auto"
|
||||
>
|
||||
{t('subscription.lavaRecurring.payFirst')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCancelLava}
|
||||
disabled={cancelLavaMutation.isPending}
|
||||
className="text-[11px] font-medium transition-colors disabled:opacity-50 sm:text-right"
|
||||
style={{ color: 'rgb(var(--color-critical-500))' }}
|
||||
>
|
||||
{t('subscription.lavaRecurring.cancel')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(lavaUiStateValue === 'active' || lavaUiStateValue === 'past_due') && (
|
||||
<button
|
||||
onClick={handleCancelLava}
|
||||
disabled={cancelLavaMutation.isPending}
|
||||
className="w-full whitespace-nowrap rounded-xl border border-error-500/30 bg-error-500/10 px-5 py-2.5 text-sm font-medium text-error-400 transition-colors hover:bg-error-500/20 disabled:opacity-50 sm:w-auto"
|
||||
>
|
||||
{t('subscription.lavaRecurring.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
|
||||
@@ -292,6 +292,12 @@ export default function SubscriptionPurchase() {
|
||||
'platega_recurrent_enabled' in purchaseOptions &&
|
||||
purchaseOptions.platega_recurrent_enabled === true
|
||||
}
|
||||
lavaPurchaseEnabled={
|
||||
isTariffsMode &&
|
||||
purchaseOptions !== undefined &&
|
||||
'lava_recurrent_enabled' in purchaseOptions &&
|
||||
purchaseOptions.lava_recurrent_enabled === true
|
||||
}
|
||||
onBack={() => {
|
||||
setShowTariffPurchase(false);
|
||||
setSelectedTariff(null);
|
||||
|
||||
@@ -9,6 +9,8 @@ import { infoApi } from '../api/info';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { logger } from '../utils/logger';
|
||||
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
|
||||
import { getApiErrorMessage } from '../utils/api-error';
|
||||
import { isOpenTicketConflict } from '../utils/ticketErrors';
|
||||
import type { SupportConfig, TicketDetail } from '../types';
|
||||
import { Card } from '@/components/data-display/Card';
|
||||
import { Button } from '@/components/primitives/Button';
|
||||
@@ -55,7 +57,10 @@ export default function Support() {
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const [replyMessage, setReplyMessage] = useState('');
|
||||
const [rateLimitError, setRateLimitError] = useState<string | null>(null);
|
||||
// Ошибка активной формы: и клиентский rate-limit, и отказ бэка (409 «уже есть
|
||||
// открытый тикет», 403 «поддержка выключена/пользователь заблокирован» и т.п.).
|
||||
// Формы create и reply взаимоисключающие, поэтому состояние одно на обе.
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
// Media attachment states (multi-upload, up to 10)
|
||||
const [createAttachments, setCreateAttachments] = useState<MediaAttachment[]>([]);
|
||||
@@ -154,11 +159,26 @@ export default function Support() {
|
||||
onSuccess: (ticket) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||
setShowCreateForm(false);
|
||||
setFormError(null);
|
||||
setNewTitle('');
|
||||
setNewMessage('');
|
||||
clearCreateAttachments();
|
||||
setSelectedTicket(ticket);
|
||||
},
|
||||
onError: (error) => {
|
||||
// Без этого отказ бэка (чаще всего 409 «уже есть открытый тикет») уходил
|
||||
// в никуда: форма просто оставалась на месте, и пользователь жал «Отправить»
|
||||
// снова и снова, не понимая, почему обращение не создаётся.
|
||||
log.error('Ticket creation failed', error);
|
||||
if (isOpenTicketConflict(error)) {
|
||||
setFormError(t('support.errors.alreadyOpenTicket'));
|
||||
// Открытый тикет мог появиться в другой сессии (бот, второе устройство) —
|
||||
// подтягиваем список, чтобы пользователю было куда перейти.
|
||||
queryClient.invalidateQueries({ queryKey: ['tickets'] });
|
||||
return;
|
||||
}
|
||||
setFormError(getApiErrorMessage(error, t('support.errors.createFailed')));
|
||||
},
|
||||
});
|
||||
|
||||
const replyMutation = useMutation({
|
||||
@@ -176,9 +196,16 @@ export default function Support() {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['ticket', selectedTicket?.id] });
|
||||
setFormError(null);
|
||||
setReplyMessage('');
|
||||
clearReplyAttachments();
|
||||
},
|
||||
onError: (error) => {
|
||||
// Ответ в тикет молчал ровно так же: 403 (блокировка в поддержке) и 400
|
||||
// (тикет уже закрыт) выглядели как «кнопка не работает».
|
||||
log.error('Ticket reply failed', error);
|
||||
setFormError(getApiErrorMessage(error, t('support.errors.replyFailed')));
|
||||
},
|
||||
});
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
@@ -321,6 +348,7 @@ export default function Support() {
|
||||
onClick={() => {
|
||||
setShowCreateForm(true);
|
||||
setSelectedTicket(null);
|
||||
setFormError(null);
|
||||
clearCreateAttachments();
|
||||
}}
|
||||
>
|
||||
@@ -374,6 +402,7 @@ export default function Support() {
|
||||
onClick={() => {
|
||||
setSelectedTicket(ticket as unknown as TicketDetail);
|
||||
setShowCreateForm(false);
|
||||
setFormError(null);
|
||||
clearReplyAttachments();
|
||||
}}
|
||||
className={`w-full rounded-bento border p-4 text-left transition-all ${
|
||||
@@ -414,11 +443,11 @@ export default function Support() {
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setRateLimitError(null);
|
||||
setFormError(null);
|
||||
// Rate limit: max 3 tickets per 60 seconds
|
||||
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_CREATE, 3, 60000)) {
|
||||
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_CREATE);
|
||||
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }));
|
||||
setFormError(t('support.tooManyRequests', { seconds: resetTime }));
|
||||
return;
|
||||
}
|
||||
createMutation.mutate();
|
||||
@@ -495,9 +524,9 @@ export default function Support() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rateLimitError && (
|
||||
{formError && (
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-3 text-sm text-error-400">
|
||||
{rateLimitError}
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -515,6 +544,7 @@ export default function Support() {
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setFormError(null);
|
||||
clearCreateAttachments();
|
||||
}}
|
||||
>
|
||||
@@ -570,7 +600,7 @@ export default function Support() {
|
||||
</div>
|
||||
{msg.message_text && (
|
||||
<div
|
||||
className="whitespace-pre-wrap text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
|
||||
className="whitespace-pre-wrap break-words text-dark-200 [&_a]:text-accent-400 [&_a]:underline"
|
||||
dangerouslySetInnerHTML={{ __html: linkifyText(msg.message_text) }}
|
||||
/>
|
||||
)}
|
||||
@@ -589,11 +619,11 @@ export default function Support() {
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setRateLimitError(null);
|
||||
setFormError(null);
|
||||
// Rate limit: max 5 replies per 30 seconds
|
||||
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_REPLY, 5, 30000)) {
|
||||
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_REPLY);
|
||||
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }));
|
||||
setFormError(t('support.tooManyRequests', { seconds: resetTime }));
|
||||
return;
|
||||
}
|
||||
replyMutation.mutate();
|
||||
@@ -662,9 +692,9 @@ export default function Support() {
|
||||
<SendIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{rateLimitError && (
|
||||
{formError && (
|
||||
<div className="mt-2 rounded-lg border border-error-500/30 bg-error-500/10 p-2 text-sm text-error-400">
|
||||
{rateLimitError}
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -48,9 +48,12 @@ interface AuthState {
|
||||
initialize: () => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
checkAdminStatus: () => Promise<void>;
|
||||
loginWithTelegram: (initData: string) => Promise<void>;
|
||||
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
|
||||
loginWithTelegramOIDC: (idToken: string) => Promise<void>;
|
||||
loginWithTelegram: (initData: string, acceptedLegalDocuments?: string[]) => Promise<void>;
|
||||
loginWithTelegramWidget: (
|
||||
data: TelegramWidgetData,
|
||||
acceptedLegalDocuments?: string[],
|
||||
) => Promise<void>;
|
||||
loginWithTelegramOIDC: (idToken: string, acceptedLegalDocuments?: string[]) => Promise<void>;
|
||||
loginWithEmail: (email: string, password: string) => Promise<void>;
|
||||
loginWithOAuth: (
|
||||
provider: string,
|
||||
@@ -64,6 +67,7 @@ interface AuthState {
|
||||
password: string,
|
||||
firstName?: string,
|
||||
referralCode?: string,
|
||||
acceptedLegalDocuments?: string[],
|
||||
) => Promise<RegisterResponse>;
|
||||
}
|
||||
|
||||
@@ -261,10 +265,15 @@ export const useAuthStore = create<AuthState>()(
|
||||
return initState.promise;
|
||||
},
|
||||
|
||||
loginWithTelegram: async (initData) => {
|
||||
loginWithTelegram: async (initData, acceptedLegalDocuments) => {
|
||||
const campaignSlug = getPendingCampaignSlug();
|
||||
const referralCode = getPendingReferralCode();
|
||||
const response = await authApi.loginTelegram(initData, campaignSlug, referralCode);
|
||||
const response = await authApi.loginTelegram(
|
||||
initData,
|
||||
campaignSlug,
|
||||
referralCode,
|
||||
acceptedLegalDocuments,
|
||||
);
|
||||
// Clear only after successful auth — retry keeps the slugs
|
||||
consumeCampaignSlug();
|
||||
consumeReferralCode();
|
||||
@@ -279,10 +288,15 @@ export const useAuthStore = create<AuthState>()(
|
||||
await get().checkAdminStatus();
|
||||
},
|
||||
|
||||
loginWithTelegramWidget: async (data) => {
|
||||
loginWithTelegramWidget: async (data, acceptedLegalDocuments) => {
|
||||
const campaignSlug = getPendingCampaignSlug();
|
||||
const referralCode = getPendingReferralCode();
|
||||
const response = await authApi.loginTelegramWidget(data, campaignSlug, referralCode);
|
||||
const response = await authApi.loginTelegramWidget(
|
||||
data,
|
||||
campaignSlug,
|
||||
referralCode,
|
||||
acceptedLegalDocuments,
|
||||
);
|
||||
consumeCampaignSlug();
|
||||
consumeReferralCode();
|
||||
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||
@@ -296,10 +310,15 @@ export const useAuthStore = create<AuthState>()(
|
||||
await get().checkAdminStatus();
|
||||
},
|
||||
|
||||
loginWithTelegramOIDC: async (idToken) => {
|
||||
loginWithTelegramOIDC: async (idToken, acceptedLegalDocuments) => {
|
||||
const campaignSlug = getPendingCampaignSlug();
|
||||
const referralCode = getPendingReferralCode();
|
||||
const response = await authApi.loginTelegramOIDC(idToken, campaignSlug, referralCode);
|
||||
const response = await authApi.loginTelegramOIDC(
|
||||
idToken,
|
||||
campaignSlug,
|
||||
referralCode,
|
||||
acceptedLegalDocuments,
|
||||
);
|
||||
consumeCampaignSlug();
|
||||
consumeReferralCode();
|
||||
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||
@@ -370,7 +389,13 @@ export const useAuthStore = create<AuthState>()(
|
||||
await get().checkAdminStatus();
|
||||
},
|
||||
|
||||
registerWithEmail: async (email, password, firstName, referralCode) => {
|
||||
registerWithEmail: async (
|
||||
email,
|
||||
password,
|
||||
firstName,
|
||||
referralCode,
|
||||
acceptedLegalDocuments,
|
||||
) => {
|
||||
const code = referralCode || getPendingReferralCode() || undefined;
|
||||
const campaignSlug = getPendingCampaignSlug() || undefined;
|
||||
const response = await authApi.registerEmailStandalone({
|
||||
@@ -380,6 +405,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
language: navigator.language.split('-')[0] || 'ru',
|
||||
referral_code: code,
|
||||
campaign_slug: campaignSlug,
|
||||
accepted_legal_documents: acceptedLegalDocuments,
|
||||
});
|
||||
consumeReferralCode();
|
||||
return response;
|
||||
|
||||
@@ -373,6 +373,7 @@ export interface TariffsPurchaseOptions {
|
||||
// СБП-оформление (Platega recurrent): показывать кнопку «Оформить с
|
||||
// автооплатой СБП» рядом с покупкой с баланса
|
||||
platega_recurrent_enabled?: boolean;
|
||||
lava_recurrent_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ClassicPurchaseOptions {
|
||||
@@ -528,6 +529,14 @@ export interface TicketDetail extends Omit<Ticket, 'messages_count' | 'last_mess
|
||||
messages: TicketMessage[];
|
||||
}
|
||||
|
||||
// Гейт согласия с офертой/политикой на экране первой авторизации.
|
||||
// documents — ключи документов, которые бэк реально требует отметить.
|
||||
export interface LegalConsentConfig {
|
||||
required: boolean;
|
||||
prechecked: boolean;
|
||||
documents: string[];
|
||||
}
|
||||
|
||||
export interface SupportConfig {
|
||||
tickets_enabled: boolean;
|
||||
support_type: 'tickets' | 'profile' | 'url' | 'both';
|
||||
@@ -661,6 +670,18 @@ export interface SbpRecurringInfo {
|
||||
redirect_url?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Автопродление Lava. В отличие от Platega период задан продуктом в кабинете
|
||||
* Lava и приезжает числом дней (charge_days), а не enum-интервалом.
|
||||
*/
|
||||
export interface LavaRecurringInfo {
|
||||
status: string; // 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE'
|
||||
charge_days?: number;
|
||||
amount_kopeks?: number;
|
||||
next_charge_at?: string | null;
|
||||
redirect_url?: string | null;
|
||||
}
|
||||
|
||||
// Ticket notifications types
|
||||
export interface TicketNotification {
|
||||
id: number;
|
||||
|
||||
27
src/utils/broadcastStatus.test.ts
Normal file
27
src/utils/broadcastStatus.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { broadcastPollInterval, isBroadcastInFlight } from './broadcastStatus';
|
||||
|
||||
describe('broadcastStatus', () => {
|
||||
it('treats queued, in-progress and cancelling deliveries as running', () => {
|
||||
expect(isBroadcastInFlight('queued')).toBe(true);
|
||||
expect(isBroadcastInFlight('in_progress')).toBe(true);
|
||||
expect(isBroadcastInFlight('cancelling')).toBe(true);
|
||||
});
|
||||
|
||||
it('treats finished deliveries as done', () => {
|
||||
expect(isBroadcastInFlight('completed')).toBe(false);
|
||||
expect(isBroadcastInFlight('partial')).toBe(false);
|
||||
expect(isBroadcastInFlight('failed')).toBe(false);
|
||||
expect(isBroadcastInFlight('cancelled')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles a missing status without polling', () => {
|
||||
expect(isBroadcastInFlight(undefined)).toBe(false);
|
||||
expect(broadcastPollInterval(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('polls only while the delivery is running', () => {
|
||||
expect(broadcastPollInterval('in_progress')).toBe(3000);
|
||||
expect(broadcastPollInterval('completed')).toBe(false);
|
||||
});
|
||||
});
|
||||
13
src/utils/broadcastStatus.ts
Normal file
13
src/utils/broadcastStatus.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/** Статусы, при которых доставка ещё идёт и запись рассылки нужно перезапрашивать. */
|
||||
export const BROADCAST_IN_FLIGHT_STATUSES = ['queued', 'in_progress', 'cancelling'] as const;
|
||||
|
||||
/** Доставка ещё не завершена: показываем прогресс и продолжаем поллинг. */
|
||||
export function isBroadcastInFlight(status: string | undefined): boolean {
|
||||
if (!status) return false;
|
||||
return (BROADCAST_IN_FLIGHT_STATUSES as readonly string[]).includes(status);
|
||||
}
|
||||
|
||||
/** Интервал поллинга для react-query: пока идёт доставка — 3 секунды, иначе не опрашиваем. */
|
||||
export function broadcastPollInterval(status: string | undefined): number | false {
|
||||
return isBroadcastInFlight(status) ? 3000 : false;
|
||||
}
|
||||
91
src/utils/campaign.test.ts
Normal file
91
src/utils/campaign.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { consumeCampaignSlug, getPendingCampaignSlug } from './campaign';
|
||||
|
||||
const CAMPAIGN_KEY = 'campaign_slug';
|
||||
const CAMPAIGN_TTL_KEY = 'campaign_slug_ttl';
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
|
||||
function fakeLocalStorage(): Storage {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear: () => store.clear(),
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
key: (index: number) => [...store.keys()][index] ?? null,
|
||||
removeItem: (key: string) => void store.delete(key),
|
||||
setItem: (key: string, value: string) => void store.set(key, value),
|
||||
} as Storage;
|
||||
}
|
||||
|
||||
function storeSlug(slug: string, expiresInMs = 24 * HOUR): void {
|
||||
localStorage.setItem(CAMPAIGN_KEY, slug);
|
||||
localStorage.setItem(CAMPAIGN_TTL_KEY, String(Date.now() + expiresInMs));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', fakeLocalStorage());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('getPendingCampaignSlug', () => {
|
||||
it('returns the stored slug', () => {
|
||||
storeSlug('summer_sale');
|
||||
|
||||
expect(getPendingCampaignSlug()).toBe('summer_sale');
|
||||
});
|
||||
|
||||
// Гостевая покупка на лендинге читает слаг этим геттером. Если он начнёт
|
||||
// очищать хранилище, тот же клиент, зайдя потом в кабинет, потеряет
|
||||
// привязку к кампании — auth-флоу уже ничего не найдёт.
|
||||
it('does not consume the slug', () => {
|
||||
storeSlug('summer_sale');
|
||||
|
||||
getPendingCampaignSlug();
|
||||
|
||||
expect(getPendingCampaignSlug()).toBe('summer_sale');
|
||||
expect(localStorage.getItem(CAMPAIGN_KEY)).toBe('summer_sale');
|
||||
});
|
||||
|
||||
it('drops an expired slug', () => {
|
||||
storeSlug('summer_sale', -HOUR);
|
||||
|
||||
expect(getPendingCampaignSlug()).toBeNull();
|
||||
expect(localStorage.getItem(CAMPAIGN_TTL_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when nothing was captured', () => {
|
||||
expect(getPendingCampaignSlug()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('consumeCampaignSlug', () => {
|
||||
it('clears the slug after reading it once', () => {
|
||||
storeSlug('summer_sale');
|
||||
|
||||
expect(consumeCampaignSlug()).toBe('summer_sale');
|
||||
expect(consumeCampaignSlug()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Компоненты в этом репозитории не рендерятся в тестах (vitest на node, без
|
||||
// jsdom), поэтому отправку слага фиксируем по исходнику: без неё покупка
|
||||
// гостем не попадает в статистику кампании и не даёт её бонус.
|
||||
describe('QuickPurchase source', () => {
|
||||
const source = readFileSync(new URL('../pages/QuickPurchase.tsx', import.meta.url), 'utf8');
|
||||
|
||||
it('puts the campaign slug into the purchase payload', () => {
|
||||
expect(source).toContain('data.campaign_slug = campaignSlug');
|
||||
});
|
||||
|
||||
it('reads the slug without consuming it', () => {
|
||||
expect(source).toContain('getPendingCampaignSlug()');
|
||||
expect(source).not.toContain('consumeCampaignSlug');
|
||||
});
|
||||
});
|
||||
127
src/utils/contactPrefill.test.ts
Normal file
127
src/utils/contactPrefill.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { readContactPrefill, stripContactFromUrl } from './contactPrefill';
|
||||
|
||||
const STORAGE_KEY = 'lp_contact_promo';
|
||||
|
||||
let replaced: string[] = [];
|
||||
|
||||
function fakeLocalStorage(): Storage {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear: () => store.clear(),
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
key: (index: number) => [...store.keys()][index] ?? null,
|
||||
removeItem: (key: string) => void store.delete(key),
|
||||
setItem: (key: string, value: string) => void store.set(key, value),
|
||||
} as Storage;
|
||||
}
|
||||
|
||||
function stubLocation(search: string, pathname = '/buy/promo', hash = ''): void {
|
||||
vi.stubGlobal('window', {
|
||||
location: { search, pathname, hash },
|
||||
history: {
|
||||
replaceState: (_state: unknown, _title: string, url: string) => replaced.push(url),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
replaced = [];
|
||||
vi.stubGlobal('localStorage', fakeLocalStorage());
|
||||
stubLocation('');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('readContactPrefill', () => {
|
||||
it('takes the contact from the URL', () => {
|
||||
stubLocation('?contact=client%40example.com');
|
||||
|
||||
expect(readContactPrefill(STORAGE_KEY)).toBe('client@example.com');
|
||||
});
|
||||
|
||||
it('keeps the @ of a telegram username', () => {
|
||||
stubLocation('?contact=%40durov');
|
||||
|
||||
expect(readContactPrefill(STORAGE_KEY)).toBe('@durov');
|
||||
});
|
||||
|
||||
it('prefers the URL over the remembered value', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'old@example.com');
|
||||
stubLocation('?contact=new%40example.com');
|
||||
|
||||
expect(readContactPrefill(STORAGE_KEY)).toBe('new@example.com');
|
||||
});
|
||||
|
||||
it('falls back to the remembered value', () => {
|
||||
localStorage.setItem(STORAGE_KEY, 'old@example.com');
|
||||
|
||||
expect(readContactPrefill(STORAGE_KEY)).toBe('old@example.com');
|
||||
});
|
||||
|
||||
it('returns an empty string when there is nothing to prefill', () => {
|
||||
expect(readContactPrefill(STORAGE_KEY)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripContactFromUrl', () => {
|
||||
// Лендинг поднимает Яндекс.Метрику с webvisor: оставленный в адресе email
|
||||
// уедет в аналитику, в Referer при переходе на оплату и в историю браузера.
|
||||
it('removes the contact from the address bar', () => {
|
||||
stubLocation('?contact=client%40example.com');
|
||||
|
||||
stripContactFromUrl();
|
||||
|
||||
expect(replaced).toEqual(['/buy/promo']);
|
||||
});
|
||||
|
||||
it('keeps the other query params', () => {
|
||||
stubLocation('?campaign=summer&contact=client%40example.com&subid=42');
|
||||
|
||||
stripContactFromUrl();
|
||||
|
||||
expect(replaced).toHaveLength(1);
|
||||
const params = new URLSearchParams(replaced[0].split('?')[1]);
|
||||
expect(params.get('campaign')).toBe('summer');
|
||||
expect(params.get('subid')).toBe('42');
|
||||
expect(params.has('contact')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the hash', () => {
|
||||
stubLocation('?contact=client%40example.com', '/buy/promo', '#tariffs');
|
||||
|
||||
stripContactFromUrl();
|
||||
|
||||
expect(replaced).toEqual(['/buy/promo#tariffs']);
|
||||
});
|
||||
|
||||
it('does not touch the URL when there is no contact param', () => {
|
||||
stubLocation('?campaign=summer');
|
||||
|
||||
stripContactFromUrl();
|
||||
|
||||
expect(replaced).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// Компоненты здесь не рендерятся (vitest на node, без jsdom), поэтому вызовы
|
||||
// фиксируем по исходнику: чтение без очистки оставит контакт в адресе, а это
|
||||
// вся суть второй функции.
|
||||
describe('QuickPurchase source', () => {
|
||||
const source = readFileSync(new URL('../pages/QuickPurchase.tsx', import.meta.url), 'utf8');
|
||||
|
||||
it('prefills the contact field from the URL', () => {
|
||||
expect(source).toContain('readContactPrefill(contactKey)');
|
||||
});
|
||||
|
||||
it('cleans the contact out of the address bar', () => {
|
||||
expect(source).toContain('stripContactFromUrl()');
|
||||
});
|
||||
});
|
||||
41
src/utils/contactPrefill.ts
Normal file
41
src/utils/contactPrefill.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Предзаполнение поля контакта на странице быстрой покупки.
|
||||
*
|
||||
* Персональную ссылку вида `/buy/slug?contact=@username` формирует бот или
|
||||
* Happ, чтобы клиенту не пришлось вводить контакт вручную.
|
||||
*/
|
||||
|
||||
const CONTACT_PARAM = 'contact';
|
||||
|
||||
/**
|
||||
* Контакт для формы: сначала параметр URL, иначе последнее введённое значение.
|
||||
*/
|
||||
export function readContactPrefill(storageKey: string): string {
|
||||
try {
|
||||
const fromUrl = new URLSearchParams(window.location.search).get(CONTACT_PARAM);
|
||||
return fromUrl || localStorage.getItem(storageKey) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Убирает `contact` из адресной строки, сохраняя остальные параметры.
|
||||
*
|
||||
* Вызывать сразу после чтения. В параметре лежит личный email или @username, а
|
||||
* лендинг инициализирует Яндекс.Метрику (та пишет URL страницы, а с
|
||||
* `webvisor` — ещё и запись сессии). Без очистки контакт уезжает в аналитику,
|
||||
* в `Referer` при переходе на оплату, в историю браузера и в логи. Тот же приём
|
||||
* применяет `captureCampaignFromUrl()`.
|
||||
*/
|
||||
export function stripContactFromUrl(): void {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!params.has(CONTACT_PARAM)) return;
|
||||
|
||||
params.delete(CONTACT_PARAM);
|
||||
const search = params.toString();
|
||||
const url = window.location.pathname + (search ? `?${search}` : '') + window.location.hash;
|
||||
window.history.replaceState(null, '', url);
|
||||
} catch {}
|
||||
}
|
||||
56
src/utils/lavaRecurring.test.ts
Normal file
56
src/utils/lavaRecurring.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isLavaFeatureDisabledError, lavaPeriodLabelKey, lavaUiState } from './lavaRecurring';
|
||||
|
||||
describe('isLavaFeatureDisabledError', () => {
|
||||
it('detects exactly the disabled-feature 403', () => {
|
||||
expect(
|
||||
isLavaFeatureDisabledError({
|
||||
response: { status: 403, data: { detail: 'Lava recurrent disabled' } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores other 403s and malformed errors', () => {
|
||||
// Другие guard'ы отдают тот же статус с иным detail — их прятать нельзя
|
||||
expect(
|
||||
isLavaFeatureDisabledError({ response: { status: 403, data: { detail: 'Blacklisted' } } }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isLavaFeatureDisabledError({ response: { status: 403, data: { detail: { code: 'x' } } } }),
|
||||
).toBe(false);
|
||||
expect(isLavaFeatureDisabledError({ response: { status: 500, data: {} } })).toBe(false);
|
||||
expect(isLavaFeatureDisabledError({ response: { status: 403 } })).toBe(false);
|
||||
expect(isLavaFeatureDisabledError(null)).toBe(false);
|
||||
expect(isLavaFeatureDisabledError('boom')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lavaUiState', () => {
|
||||
it('hides the block when the feature is disabled', () => {
|
||||
expect(lavaUiState({ status: 'ACTIVE' }, true)).toBe('hidden');
|
||||
});
|
||||
|
||||
it('maps backend statuses to UI states', () => {
|
||||
expect(lavaUiState({ status: 'PENDING' }, false)).toBe('pending');
|
||||
expect(lavaUiState({ status: 'ACTIVE' }, false)).toBe('active');
|
||||
expect(lavaUiState({ status: 'PAST_DUE' }, false)).toBe('past_due');
|
||||
expect(lavaUiState({ status: 'none' }, false)).toBe('off');
|
||||
});
|
||||
|
||||
it('degrades unknown or missing state to off instead of hanging', () => {
|
||||
expect(lavaUiState({ status: 'SOMETHING_NEW' }, false)).toBe('off');
|
||||
expect(lavaUiState(undefined, false)).toBe('off');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lavaPeriodLabelKey', () => {
|
||||
it('labels canonical periods', () => {
|
||||
expect(lavaPeriodLabelKey(30)).toBe('subscription.lavaRecurring.period.month');
|
||||
expect(lavaPeriodLabelKey(365)).toBe('subscription.lavaRecurring.period.year');
|
||||
});
|
||||
|
||||
it('returns null for non-canonical periods so the caller falls back to "N days"', () => {
|
||||
expect(lavaPeriodLabelKey(45)).toBeNull();
|
||||
expect(lavaPeriodLabelKey(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
77
src/utils/lavaRecurring.ts
Normal file
77
src/utils/lavaRecurring.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { LavaRecurringInfo } from '../types';
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* true, если ошибка — это ровно "автопродление Lava выключено на бэке"
|
||||
* (403 с detail==='Lava recurrent disabled'). Сравнение строгое: тот же
|
||||
* 403-статус используют другие guard'ы с detail-объектом, и на любой форме
|
||||
* detail (строка/объект/отсутствует) функция не должна падать.
|
||||
*/
|
||||
export function isLavaFeatureDisabledError(error: unknown): boolean {
|
||||
if (!isRecord(error)) return false;
|
||||
|
||||
const response = error.response;
|
||||
if (!isRecord(response)) return false;
|
||||
if (response.status !== 403) return false;
|
||||
|
||||
const data = response.data;
|
||||
if (!isRecord(data)) return false;
|
||||
|
||||
return data.detail === 'Lava recurrent disabled';
|
||||
}
|
||||
|
||||
export type LavaUiState = 'hidden' | 'off' | 'pending' | 'active' | 'past_due';
|
||||
|
||||
/**
|
||||
* Сводит статус автопродления Lava к состоянию UI. Бэк отдаёт из GET только
|
||||
* 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE' (CANCELLED/FAILED терминальны и
|
||||
* не возвращаются), но любой нераспознанный статус деградирует в 'off', а не
|
||||
* подвешивает интерфейс.
|
||||
*/
|
||||
export function lavaUiState(
|
||||
info: LavaRecurringInfo | undefined,
|
||||
featureDisabled: boolean,
|
||||
): LavaUiState {
|
||||
if (featureDisabled) return 'hidden';
|
||||
if (!info) return 'off';
|
||||
|
||||
switch (info.status) {
|
||||
case 'PENDING':
|
||||
return 'pending';
|
||||
case 'ACTIVE':
|
||||
return 'active';
|
||||
case 'PAST_DUE':
|
||||
return 'past_due';
|
||||
default:
|
||||
return 'off';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Локализационный ключ периодичности списания. У Lava период задан продуктом
|
||||
* в кабинете провайдера и приезжает числом дней, поэтому точные подписи есть
|
||||
* только для канонических значений; остальное показывается как "N дней".
|
||||
*/
|
||||
export function lavaPeriodLabelKey(chargeDays: number | undefined): string | null {
|
||||
switch (chargeDays) {
|
||||
case 1:
|
||||
return 'subscription.lavaRecurring.period.day';
|
||||
case 7:
|
||||
return 'subscription.lavaRecurring.period.week';
|
||||
case 30:
|
||||
return 'subscription.lavaRecurring.period.month';
|
||||
case 90:
|
||||
return 'subscription.lavaRecurring.period.quarter';
|
||||
case 180:
|
||||
return 'subscription.lavaRecurring.period.halfYear';
|
||||
case 365:
|
||||
return 'subscription.lavaRecurring.period.year';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
52
src/utils/nodeVersion.test.ts
Normal file
52
src/utils/nodeVersion.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GEOCHECK_MIN_NODE_VERSION, isVersionAtLeast, supportsGeoCheck } from './nodeVersion';
|
||||
|
||||
describe('isVersionAtLeast', () => {
|
||||
it.each([
|
||||
['3.3.0', true],
|
||||
['3.3.1', true],
|
||||
['3.4.0', true],
|
||||
['4.0.0', true],
|
||||
['v3.3.0', true],
|
||||
['3.3', true],
|
||||
['3.2.9', false],
|
||||
['3.2.99', false],
|
||||
['2.9.9', false],
|
||||
['3', false],
|
||||
])('%s vs 3.3.0 -> %s', (value, expected) => {
|
||||
expect(isVersionAtLeast(value, '3.3.0')).toBe(expected);
|
||||
});
|
||||
|
||||
it('сравнивает числа, а не строки: 3.10.0 новее 3.9.0', () => {
|
||||
expect(isVersionAtLeast('3.10.0', '3.9.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('пререлиз целевой версии считается достаточным — фича там уже есть', () => {
|
||||
expect(isVersionAtLeast('3.3.0-rc.1', '3.3.0')).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
null,
|
||||
'',
|
||||
'unknown',
|
||||
'dev',
|
||||
])('нераспознанное значение %s -> false', (value) => {
|
||||
expect(isVersionAtLeast(value, '3.3.0')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsGeoCheck', () => {
|
||||
it('смотрит именно на версию узла, а не xray', () => {
|
||||
expect(supportsGeoCheck({ node: '3.3.0' })).toBe(true);
|
||||
expect(supportsGeoCheck({ node: '3.2.0' })).toBe(false);
|
||||
});
|
||||
|
||||
it.each([undefined, null, {}])('без версии узла — false (%s)', (versions) => {
|
||||
expect(supportsGeoCheck(versions as { node?: string } | null | undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('порог совпадает с задокументированным минимумом', () => {
|
||||
expect(supportsGeoCheck({ node: GEOCHECK_MIN_NODE_VERSION })).toBe(true);
|
||||
});
|
||||
});
|
||||
43
src/utils/nodeVersion.ts
Normal file
43
src/utils/nodeVersion.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Сравнение версий узла Remnawave.
|
||||
*
|
||||
* Панель отдаёт `versions: { xray, node }` строками вида `3.3.0`; иногда с
|
||||
* префиксом `v` или суффиксом пререлиза (`3.3.0-rc.1`). Нам нужен только
|
||||
* порог «фича появилась начиная с версии X», поэтому сравниваем три числа
|
||||
* и намеренно НЕ разбираем пререлизы по правилам semver: `3.3.0-rc.1`
|
||||
* считается достаточным для 3.3.0 — фича там уже есть.
|
||||
*/
|
||||
|
||||
/** Версия узла, при которой в панели появился GeoCheck. */
|
||||
export const GEOCHECK_MIN_NODE_VERSION = '3.3.0';
|
||||
|
||||
function parseVersion(value: string): [number, number, number] | null {
|
||||
const match = /^\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(value);
|
||||
if (!match) return null;
|
||||
return [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)];
|
||||
}
|
||||
|
||||
/** `true`, если `value` не старше `minimum`. Нераспознанная версия — `false`. */
|
||||
export function isVersionAtLeast(value: string | null | undefined, minimum: string): boolean {
|
||||
if (!value) return false;
|
||||
|
||||
const parsed = parseVersion(value);
|
||||
const floor = parseVersion(minimum);
|
||||
if (!parsed || !floor) return false;
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
if (parsed[i] !== floor[i]) return parsed[i] > floor[i];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Умеет ли узел в GeoCheck.
|
||||
*
|
||||
* Гейт по версии УЗЛА, а не панели: панель без 3.3.0 ответит на запрос 404, и
|
||||
* бэкенд превратит это в понятную ошибку — а вот кнопку на заведомо старом
|
||||
* узле показывать незачем.
|
||||
*/
|
||||
export function supportsGeoCheck(versions: { node?: string } | null | undefined): boolean {
|
||||
return isVersionAtLeast(versions?.node, GEOCHECK_MIN_NODE_VERSION);
|
||||
}
|
||||
26
src/utils/pricing.test.ts
Normal file
26
src/utils/pricing.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getMonthlyPriceKopeks } from './pricing';
|
||||
|
||||
describe('getMonthlyPriceKopeks', () => {
|
||||
it('hides the monthly rate for periods of a month or shorter', () => {
|
||||
expect(getMonthlyPriceKopeks(4830, 7)).toBeNull();
|
||||
expect(getMonthlyPriceKopeks(9900, 14)).toBeNull();
|
||||
expect(getMonthlyPriceKopeks(16030, 30)).toBeNull();
|
||||
});
|
||||
|
||||
it('divides by whole months for multiples of 30 days', () => {
|
||||
expect(getMonthlyPriceKopeks(30000, 90)).toBe(10000);
|
||||
expect(getMonthlyPriceKopeks(60000, 180)).toBe(10000);
|
||||
});
|
||||
|
||||
it('prorates periods that are not whole months', () => {
|
||||
expect(getMonthlyPriceKopeks(15000, 45)).toBe(10000);
|
||||
expect(getMonthlyPriceKopeks(100000, 365)).toBe(8219);
|
||||
});
|
||||
|
||||
it('returns null for non-finite input', () => {
|
||||
expect(getMonthlyPriceKopeks(Number.NaN, 90)).toBeNull();
|
||||
expect(getMonthlyPriceKopeks(30000, Number.NaN)).toBeNull();
|
||||
expect(getMonthlyPriceKopeks(30000, Number.POSITIVE_INFINITY)).toBeNull();
|
||||
});
|
||||
});
|
||||
15
src/utils/pricing.ts
Normal file
15
src/utils/pricing.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** Длина «месяца» в днях — тот же множитель, что использует бэкенд при расчёте цены за месяц. */
|
||||
const DAYS_IN_MONTH = 30;
|
||||
|
||||
/**
|
||||
* Цена за месяц (в копейках) для периода длиной `days` дней.
|
||||
*
|
||||
* Возвращает `null`, когда месячная ставка не имеет смысла: для периода
|
||||
* в месяц и короче она либо повторяет цену периода (30 дней), либо
|
||||
* выдаёт цену периода за месячную (7 дней → цена семи дней «за месяц»).
|
||||
*/
|
||||
export function getMonthlyPriceKopeks(priceKopeks: number, days: number): number | null {
|
||||
if (!Number.isFinite(priceKopeks) || !Number.isFinite(days)) return null;
|
||||
if (days <= DAYS_IN_MONTH) return null;
|
||||
return Math.round((priceKopeks * DAYS_IN_MONTH) / days);
|
||||
}
|
||||
30
src/utils/qrScanner.test.ts
Normal file
30
src/utils/qrScanner.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseGiftCode } from './qrScanner';
|
||||
|
||||
describe('parseGiftCode', () => {
|
||||
it('reads the bot deep link (main distribution path)', () => {
|
||||
expect(parseGiftCode('https://t.me/mybot?start=GIFT_abc123def456')).toBe('abc123def456');
|
||||
});
|
||||
|
||||
it('reads the cabinet activation link', () => {
|
||||
expect(parseGiftCode('https://cab.example.com/gift?tab=activate&code=abc123def456')).toBe(
|
||||
'abc123def456',
|
||||
);
|
||||
});
|
||||
|
||||
it('reads a bare code with and without the GIFT prefix', () => {
|
||||
expect(parseGiftCode('GIFT-abc123def456')).toBe('abc123def456');
|
||||
expect(parseGiftCode('abc123def456')).toBe('abc123def456');
|
||||
expect(parseGiftCode(' abc123def456 ')).toBe('abc123def456');
|
||||
});
|
||||
|
||||
it('rejects unrelated QR content instead of filling the field with junk', () => {
|
||||
// Иначе сканер «распознал» бы любую ссылку и подставил мусор в поле кода
|
||||
expect(parseGiftCode('https://example.com/some/page')).toBeNull();
|
||||
expect(parseGiftCode('WIFI:S:MyNet;T:WPA;P:secret;;')).toBeNull();
|
||||
expect(parseGiftCode('short')).toBeNull();
|
||||
expect(parseGiftCode('')).toBeNull();
|
||||
expect(parseGiftCode(null)).toBeNull();
|
||||
expect(parseGiftCode(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
106
src/utils/qrScanner.ts
Normal file
106
src/utils/qrScanner.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
isQrScannerSupported,
|
||||
openQrScanner,
|
||||
retrieveLaunchParams,
|
||||
} from '@telegram-apps/sdk-react';
|
||||
|
||||
// Сканирование QR внутри Telegram: WebView не даёт нормального доступа к камере,
|
||||
// поэтому на мобильных клиентах используется НАТИВНЫЙ сканер Telegram, а на вебе
|
||||
// — html5-qrcode с CDN. Логика вынесена из TvQuickConnect, чтобы её мог
|
||||
// переиспользовать любой экран (подарки и т.д.).
|
||||
|
||||
const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']);
|
||||
|
||||
export const HTML5_QRCODE_CDN = 'https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js';
|
||||
// SRI-хеш ИМЕННО этого файла (sha384 от html5-qrcode@2.3.8/html5-qrcode.min.js).
|
||||
// Без него подмена на стороне CDN исполнилась бы в кабинете с полными правами
|
||||
// страницы. При несовпадении браузер откажется исполнять скрипт — сканер
|
||||
// деградирует в «камера недоступна», а не запускает чужой код.
|
||||
const HTML5_QRCODE_SRI = 'sha384-c9d8RFSL+u3exBOJ4Yp3HUJXS4znl9f+z66d1y54ig+ea249SpqR+w1wyvXz/lk+';
|
||||
|
||||
export interface Html5QrcodeInstance {
|
||||
start: (
|
||||
camera: { facingMode: string },
|
||||
config: { fps: number; qrbox: { width: number; height: number } },
|
||||
onSuccess: (decoded: string) => void,
|
||||
onError: () => void,
|
||||
) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance };
|
||||
|
||||
export function isTelegramMobile(): boolean {
|
||||
try {
|
||||
return TG_MOBILE_PLATFORMS.has(retrieveLaunchParams().tgWebAppPlatform);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** true — доступен нативный сканер Telegram (мобильный клиент). */
|
||||
export function canUseTelegramScanner(): boolean {
|
||||
try {
|
||||
return isTelegramMobile() && isQrScannerSupported() && openQrScanner.isAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Открывает нативный сканер Telegram. Возвращает распознанную строку либо null,
|
||||
* если пользователь закрыл сканер (или он недоступен).
|
||||
*/
|
||||
export async function scanWithTelegram(
|
||||
text: string,
|
||||
capture?: (value: string) => boolean,
|
||||
): Promise<string | null> {
|
||||
const result = await openQrScanner({ text, capture: capture ?? (() => true) });
|
||||
return result ?? null;
|
||||
}
|
||||
|
||||
/** Подгружает html5-qrcode с CDN (в бандл не тянем — нужен редко). */
|
||||
export async function loadHtml5Qrcode(): Promise<WindowWithHtml5['Html5Qrcode'] | undefined> {
|
||||
const w = window as WindowWithHtml5;
|
||||
if (w.Html5Qrcode) return w.Html5Qrcode;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = HTML5_QRCODE_CDN;
|
||||
script.integrity = HTML5_QRCODE_SRI;
|
||||
script.crossOrigin = 'anonymous';
|
||||
script.referrerPolicy = 'no-referrer';
|
||||
document.head.appendChild(script);
|
||||
await new Promise<void>((resolve) => {
|
||||
script.onload = () => resolve();
|
||||
// onerror срабатывает и при провале проверки целостности — вызывающий
|
||||
// получит undefined и покажет «камера недоступна».
|
||||
script.onerror = () => resolve();
|
||||
});
|
||||
return w.Html5Qrcode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Достаёт код подарка из отсканированной строки.
|
||||
*
|
||||
* Один и тот же подарок распространяется тремя способами, и скан должен понимать
|
||||
* все: deep-link бота (``?start=GIFT_<code>``), ссылка кабинета
|
||||
* (``/gift?tab=activate&code=<code>``) и просто код (``GIFT-xxxx`` или голый).
|
||||
*/
|
||||
export function parseGiftCode(raw: string | null | undefined): string | null {
|
||||
const value = (raw || '').trim();
|
||||
if (!value) return null;
|
||||
|
||||
const startParam = value.match(/[?&]start=GIFT_([A-Za-z0-9_-]+)/i);
|
||||
if (startParam) return startParam[1];
|
||||
|
||||
const cabinetParam = value.match(/[?&]code=([A-Za-z0-9_-]+)/i);
|
||||
if (cabinetParam) return cabinetParam[1];
|
||||
|
||||
// Голый код: допускаем префикс GIFT- и разделители, но не произвольный текст —
|
||||
// иначе сканер «распознал» бы любую ссылку и подставил мусор в поле.
|
||||
const bare = value.replace(/^GIFT[-_]/i, '');
|
||||
if (/^[A-Za-z0-9]{6,64}$/.test(bare)) return bare;
|
||||
|
||||
return null;
|
||||
}
|
||||
139
src/utils/telegramInitData.test.ts
Normal file
139
src/utils/telegramInitData.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const retrieveRawInitData = vi.fn<() => string | undefined>();
|
||||
|
||||
vi.mock('@telegram-apps/sdk-react', () => ({
|
||||
retrieveRawInitData: () => retrieveRawInitData(),
|
||||
}));
|
||||
|
||||
const { getTelegramInitData } = await import('./telegramInitData');
|
||||
|
||||
/** initData ровно той формы, что приходит от Telegram. */
|
||||
function initData(authDate: number): string {
|
||||
const user = encodeURIComponent(JSON.stringify({ id: 1, first_name: 'A' }));
|
||||
return `user=${user}&auth_date=${authDate}&signature=s&hash=h${authDate}`;
|
||||
}
|
||||
|
||||
const OLD = initData(1_700_000_000);
|
||||
const FRESH = initData(1_755_000_000);
|
||||
|
||||
function setBridge(value: string | undefined): void {
|
||||
vi.stubGlobal('window', value === undefined ? {} : { Telegram: { WebApp: { initData: value } } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
retrieveRawInitData.mockReset();
|
||||
setBridge(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('getTelegramInitData', () => {
|
||||
it('uses the Telegram bridge value', () => {
|
||||
setBridge(FRESH);
|
||||
retrieveRawInitData.mockReturnValue(undefined);
|
||||
|
||||
expect(getTelegramInitData()).toBe(FRESH);
|
||||
});
|
||||
|
||||
it('falls back to the SDK when the bridge script has not loaded', () => {
|
||||
setBridge(undefined);
|
||||
retrieveRawInitData.mockReturnValue(FRESH);
|
||||
|
||||
expect(getTelegramInitData()).toBe(FRESH);
|
||||
});
|
||||
|
||||
// Ради этого правка и делается: SDK берёт параметры запуска из записи о
|
||||
// навигации и из своего кэша в sessionStorage, а оба привязаны к документу,
|
||||
// а не к текущему запуску мини-аппы. На iOS WebView переживает переоткрытия,
|
||||
// и SDK молча отдаёт initData прошлой недели — бэкенд её отвергает.
|
||||
it('prefers the bridge when the SDK serves a stale cached launch', () => {
|
||||
setBridge(FRESH);
|
||||
retrieveRawInitData.mockReturnValue(OLD);
|
||||
|
||||
expect(getTelegramInitData()).toBe(FRESH);
|
||||
});
|
||||
|
||||
// Обратный случай встречается на других платформах, поэтому выбираем не
|
||||
// «первый доступный», а самый свежий по auth_date.
|
||||
it('prefers the SDK when the bridge itself is the stale one', () => {
|
||||
setBridge(OLD);
|
||||
retrieveRawInitData.mockReturnValue(FRESH);
|
||||
|
||||
expect(getTelegramInitData()).toBe(FRESH);
|
||||
});
|
||||
|
||||
it('keeps the bridge value when both are equally fresh', () => {
|
||||
const bridgeCopy = `${FRESH}&tgWebAppBotInline=0`;
|
||||
setBridge(bridgeCopy);
|
||||
retrieveRawInitData.mockReturnValue(FRESH);
|
||||
|
||||
expect(getTelegramInitData()).toBe(bridgeCopy);
|
||||
});
|
||||
|
||||
it('treats an empty bridge value as absent', () => {
|
||||
setBridge('');
|
||||
retrieveRawInitData.mockReturnValue(FRESH);
|
||||
|
||||
expect(getTelegramInitData()).toBe(FRESH);
|
||||
});
|
||||
|
||||
it('survives the SDK throwing outside Telegram', () => {
|
||||
setBridge(FRESH);
|
||||
retrieveRawInitData.mockImplementation(() => {
|
||||
throw new Error('LaunchParamsRetrieveError');
|
||||
});
|
||||
|
||||
expect(getTelegramInitData()).toBe(FRESH);
|
||||
});
|
||||
|
||||
it('returns null when there is no init data at all', () => {
|
||||
setBridge(undefined);
|
||||
retrieveRawInitData.mockImplementation(() => {
|
||||
throw new Error('LaunchParamsRetrieveError');
|
||||
});
|
||||
|
||||
expect(getTelegramInitData()).toBeNull();
|
||||
});
|
||||
|
||||
it('does not crash on init data without a usable auth_date', () => {
|
||||
setBridge('user=%7B%7D&hash=h');
|
||||
retrieveRawInitData.mockReturnValue('auth_date=not-a-number&hash=h');
|
||||
|
||||
expect(getTelegramInitData()).toBe('user=%7B%7D&hash=h');
|
||||
});
|
||||
});
|
||||
|
||||
// Смысл правки — единственная точка чтения initData. Прямой вызов
|
||||
// retrieveRawInitData() в обход неё вернёт ту самую протухшую копию из кэша
|
||||
// SDK, причём молча: ошибки не будет, вход просто перестанет работать.
|
||||
describe('single source of init data', () => {
|
||||
it('is read through this module only', async () => {
|
||||
const { readdirSync, readFileSync } = await import('node:fs');
|
||||
const { join } = await import('node:path');
|
||||
|
||||
const srcDir = new URL('..', import.meta.url).pathname;
|
||||
const offenders: string[] = [];
|
||||
|
||||
const walk = (dir: string): void => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
if (!/\.tsx?$/.test(entry.name)) continue;
|
||||
if (full.endsWith(join('utils', 'telegramInitData.ts'))) continue;
|
||||
if (full.endsWith(join('utils', 'telegramInitData.test.ts'))) continue;
|
||||
if (readFileSync(full, 'utf8').includes('retrieveRawInitData')) {
|
||||
offenders.push(full.slice(srcDir.length));
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(srcDir);
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
63
src/utils/telegramInitData.ts
Normal file
63
src/utils/telegramInitData.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { retrieveRawInitData } from '@telegram-apps/sdk-react';
|
||||
|
||||
/**
|
||||
* Сырая строка initData Telegram, прочитанная в момент вызова.
|
||||
*
|
||||
* Почему мало одного `retrieveRawInitData()` из @telegram-apps/sdk: он ищет
|
||||
* параметры запуска по цепочке `location.href` → запись performance о навигации
|
||||
* → собственный кэш `tapps/launchParams` в sessionStorage, записывая удачный
|
||||
* результат обратно в кэш. Последние два источника привязаны не к текущему
|
||||
* запуску мини-аппы, а к документу: как только SPA сменила маршрут и hash с
|
||||
* `tgWebAppData` ушёл из адреса, остаются запись о навигации исходного
|
||||
* документа и кэш — оба из ПЕРВОГО запуска в этой сессии WebView. На iOS такой
|
||||
* WebView переживает переоткрытия мини-аппы, и SDK молча отдаёт initData
|
||||
* недельной давности, без единой ошибки.
|
||||
*
|
||||
* Цена — отказ входа: бэкенд принимает initData с `auth_date` не старше 30
|
||||
* суток, и застрявшая копия рано или поздно пересекает порог, после чего
|
||||
* логин отвечает «Invalid or expired Telegram authentication data».
|
||||
*
|
||||
* Поэтому спрашиваем ещё и `window.Telegram.WebApp.initData` — его пишет
|
||||
* официальный мост Telegram (telegram-web-app.js, подключается в index.html),
|
||||
* и он не завязан на кэш SDK. Какой из источников протух, зависит от платформы
|
||||
* и способа открытия, поэтому берём не «первый доступный», а тот, у которого
|
||||
* `auth_date` больше.
|
||||
*/
|
||||
export function getTelegramInitData(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const candidates = [fromTelegramBridge(), fromSdk()].filter((value): value is string =>
|
||||
Boolean(value),
|
||||
);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// При равном или неразобранном auth_date побеждает мост: он ближе к
|
||||
// источнику, чем кэш SDK.
|
||||
return candidates.reduce((best, candidate) =>
|
||||
authDateOf(candidate) > authDateOf(best) ? candidate : best,
|
||||
);
|
||||
}
|
||||
|
||||
function fromTelegramBridge(): string | null {
|
||||
return window.Telegram?.WebApp?.initData || null;
|
||||
}
|
||||
|
||||
function fromSdk(): string | null {
|
||||
try {
|
||||
return retrieveRawInitData() || null;
|
||||
} catch {
|
||||
// Вне Telegram параметров запуска нет — это не ошибка.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Метка времени выдачи initData; 0, если её не удалось разобрать. */
|
||||
function authDateOf(initData: string): number {
|
||||
try {
|
||||
const raw = new URLSearchParams(initData).get('auth_date');
|
||||
const parsed = Number(raw);
|
||||
return raw && Number.isFinite(parsed) ? parsed : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
85
src/utils/ticketErrors.test.ts
Normal file
85
src/utils/ticketErrors.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { AxiosError, AxiosHeaders } from 'axios';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isOpenTicketConflict } from './ticketErrors';
|
||||
|
||||
function axiosErrorWithStatus(status: number, detail?: unknown): AxiosError {
|
||||
const headers = new AxiosHeaders();
|
||||
const config = { headers };
|
||||
return new AxiosError(
|
||||
'Request failed',
|
||||
'ERR_BAD_REQUEST',
|
||||
config,
|
||||
{},
|
||||
{
|
||||
status,
|
||||
statusText: '',
|
||||
headers,
|
||||
config,
|
||||
data: detail === undefined ? {} : { detail },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('isOpenTicketConflict', () => {
|
||||
it('ловит 409 от POST /cabinet/tickets', () => {
|
||||
expect(isOpenTicketConflict(axiosErrorWithStatus(409, 'You already have an open ticket'))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('не зависит от текста detail (он приходит с бэка по-английски)', () => {
|
||||
expect(isOpenTicketConflict(axiosErrorWithStatus(409))).toBe(true);
|
||||
expect(isOpenTicketConflict(axiosErrorWithStatus(409, { code: 'open_ticket_exists' }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('не путает с другими отказами бэка', () => {
|
||||
expect(isOpenTicketConflict(axiosErrorWithStatus(403, 'Support tickets are disabled'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isOpenTicketConflict(axiosErrorWithStatus(400))).toBe(false);
|
||||
expect(isOpenTicketConflict(axiosErrorWithStatus(500))).toBe(false);
|
||||
});
|
||||
|
||||
it('не падает на не-axios ошибках', () => {
|
||||
expect(isOpenTicketConflict(new Error('boom'))).toBe(false);
|
||||
expect(isOpenTicketConflict(undefined)).toBe(false);
|
||||
expect(isOpenTicketConflict(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('обработчики ошибок на форме поддержки', () => {
|
||||
// Решение «это 409» вынесено в утилиту и покрыто выше, а вот сама привязка
|
||||
// onError к мутациям — нет: в репозитории нет окружения для рендера
|
||||
// компонентов (vitest настроен на node, без jsdom). Поэтому закрепляем
|
||||
// структурно: пропажа onError — это ровно тот баг, который PR и чинит
|
||||
// (форма молчала, человек жал «Отправить» повторно).
|
||||
const source = readFileSync(new URL('../pages/Support.tsx', import.meta.url), 'utf8');
|
||||
|
||||
it('createMutation обрабатывает отказ бэка', () => {
|
||||
const block = source.slice(
|
||||
source.indexOf('const createMutation'),
|
||||
source.indexOf('const replyMutation'),
|
||||
);
|
||||
expect(block).toContain('onError');
|
||||
expect(block).toContain('isOpenTicketConflict');
|
||||
});
|
||||
|
||||
it('replyMutation обрабатывает отказ бэка', () => {
|
||||
const block = source.slice(
|
||||
source.indexOf('const replyMutation'),
|
||||
source.indexOf('const getStatusBadge'),
|
||||
);
|
||||
expect(block).toContain('onError');
|
||||
});
|
||||
|
||||
it('409 подтягивает список тикетов — открытый мог появиться в другой сессии', () => {
|
||||
const block = source.slice(
|
||||
source.indexOf('isOpenTicketConflict(error)'),
|
||||
source.indexOf('const replyMutation'),
|
||||
);
|
||||
expect(block).toContain('invalidateQueries');
|
||||
});
|
||||
});
|
||||
14
src/utils/ticketErrors.ts
Normal file
14
src/utils/ticketErrors.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import axios from 'axios';
|
||||
|
||||
/**
|
||||
* true, если бэк отказал в создании тикета из-за уже открытого обращения.
|
||||
*
|
||||
* `POST /cabinet/tickets` отвечает 409 ровно в одном случае — у пользователя
|
||||
* есть незакрытый тикет (`detail: 'You already have an open ticket'`, паритет
|
||||
* с бот-путём). Сверяемся со статусом, а не с текстом: detail приходит с бэка
|
||||
* только по-английски, а кабинет и бот версионируются раздельно — текст может
|
||||
* поменяться в любой сборке бота, статус останется.
|
||||
*/
|
||||
export function isOpenTicketConflict(error: unknown): boolean {
|
||||
return axios.isAxiosError(error) && error.response?.status === 409;
|
||||
}
|
||||
46
src/utils/ticketMessageWrap.test.ts
Normal file
46
src/utils/ticketMessageWrap.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Тело сообщения тикета рендерится в трёх местах, и во всех трёх текст должен
|
||||
* переноситься по любому символу.
|
||||
*
|
||||
* `whitespace-pre-wrap` переносит только по пробелам, поэтому неразрывный токен
|
||||
* — ссылка на подписку, ключ, base64 — уезжает за пузырь. Заметить это нельзя:
|
||||
* контейнер сообщений идёт с `overflow-y-auto` (браузер добирает `overflow-x`
|
||||
* в `auto`), а полосу прокрутки прячет `.scrollbar-hide`. Замер в браузере на
|
||||
* ширине 1280px: без `break-words` контейнер 803px против содержимого 3179px,
|
||||
* то есть 2376px текста недостижимы ни колесом, ни глазом.
|
||||
*
|
||||
* Компоненты здесь не рендерятся (vitest на node, без jsdom), поэтому класс
|
||||
* проверяется по исходнику — рядом с тем самым `linkifyText(msg.message_text)`,
|
||||
* а не где угодно в файле.
|
||||
*/
|
||||
|
||||
const RENDERERS = [
|
||||
'src/pages/Support.tsx',
|
||||
'src/pages/AdminTickets.tsx',
|
||||
'src/components/admin/userDetail/TicketsTab.tsx',
|
||||
];
|
||||
|
||||
// className="..." непосредственно перед выводом тела сообщения
|
||||
const MESSAGE_BODY_RE =
|
||||
/className="([^"]*)"\s*\n\s*dangerouslySetInnerHTML=\{\{\s*__html:\s*linkifyText\(msg\.message_text\)/g;
|
||||
|
||||
function messageBodyClasses(file: string): string[] {
|
||||
const source = readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8');
|
||||
return [...source.matchAll(MESSAGE_BODY_RE)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
describe.each(RENDERERS)('%s', (file) => {
|
||||
it('renders the ticket message body', () => {
|
||||
expect(messageBodyClasses(file).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('wraps long unbreakable tokens', () => {
|
||||
for (const classes of messageBodyClasses(file)) {
|
||||
expect(classes).toContain('whitespace-pre-wrap');
|
||||
expect(classes).toContain('break-words');
|
||||
}
|
||||
});
|
||||
});
|
||||
6
src/vite-env.d.ts
vendored
6
src/vite-env.d.ts
vendored
@@ -21,6 +21,12 @@ interface TelegramWebAppGlobal {
|
||||
offEvent?: (event: string, callback: () => void) => void;
|
||||
/** Closes the Mini App (injected by telegram-web-app.js). */
|
||||
close?: () => void;
|
||||
/**
|
||||
* Raw init data, snapshotted by Telegram's own bridge script at page load.
|
||||
* Independent of the @telegram-apps/sdk launch-params cache, which can go
|
||||
* stale — see src/utils/telegramInitData.ts.
|
||||
*/
|
||||
initData?: string;
|
||||
}
|
||||
|
||||
/** Telegram Login JS SDK — loaded from https://oauth.telegram.org/js/telegram-login.js */
|
||||
|
||||
Reference in New Issue
Block a user