Merge pull request #156 from BEDOLAGA-DEV/dev

Release
This commit is contained in:
c0mrade
2026-02-06 23:34:12 +03:00
committed by GitHub
153 changed files with 3909 additions and 5935 deletions

View File

@@ -1,10 +1,9 @@
---
name: Bug Bedolage
about: Создать баг-репорт «Бедолаге»
title: "[BUG] Заголовок"
title: '[BUG] Заголовок'
labels: ''
assignees: ''
---
## Описание / Конфигурация
@@ -25,9 +24,9 @@ assignees: ''
### Фактический результат
*
*
*
-
-
-
### Скрины поведения

View File

@@ -1,10 +1,9 @@
---
name: Feat Bedolage
about: Создать запрос на фичу «Бедолаге»
title: "[FEAT] Заголовок"
title: '[FEAT] Заголовок'
labels: ''
assignees: ''
---
## Описание / Конфигурация

2
.gitignore vendored
View File

@@ -46,3 +46,5 @@ miniapp/
# AI & Agents context
.ai/
CLAUDE.md
**/CLAUDE.md

View File

@@ -2,102 +2,96 @@
## [1.7.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.6.2...v1.7.0) (2026-02-04)
### Features
* add Stars payment confirmation and admin validation ([e6f8ae6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e6f8ae6ab09c431d5322c851165f30469678ed72))
* replace payment modals with page-based navigation ([576893f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/576893f5c6b67c19bee0cd562cd0430a88350619))
- add Stars payment confirmation and admin validation ([e6f8ae6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e6f8ae6ab09c431d5322c851165f30469678ed72))
- replace payment modals with page-based navigation ([576893f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/576893f5c6b67c19bee0cd562cd0430a88350619))
### Bug Fixes
* dim accent color for background blobs ([bb32cd8](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bb32cd8757b116728c0e7357fc40bcb842e7a476))
* inline Stars confirmation and unified payment type display ([8068f84](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8068f847247307aa3adae1f6965987882be8a785))
* prevent payment type reset after wheel spin ([4499c9a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4499c9ad57dcdcd3126c8d4261bc9f32accd21d7))
* unify wheel Stars payment across desktop and mobile ([02640d1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/02640d1c38dde00d431058a399adcc85fd9bcaac))
* update Aurora colors reactively without recreating WebGL context ([59a251c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/59a251cb8c706b3029990e58fc6003ce620f80d3))
- dim accent color for background blobs ([bb32cd8](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bb32cd8757b116728c0e7357fc40bcb842e7a476))
- inline Stars confirmation and unified payment type display ([8068f84](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8068f847247307aa3adae1f6965987882be8a785))
- prevent payment type reset after wheel spin ([4499c9a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4499c9ad57dcdcd3126c8d4261bc9f32accd21d7))
- unify wheel Stars payment across desktop and mobile ([02640d1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/02640d1c38dde00d431058a399adcc85fd9bcaac))
- update Aurora colors reactively without recreating WebGL context ([59a251c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/59a251cb8c706b3029990e58fc6003ce620f80d3))
## [1.6.2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.6.1...v1.6.2) (2026-02-04)
### Bug Fixes
* add theme toggle to desktop header and sync theme across components ([bf00d37](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bf00d37b4af799130e3dd8cd2c083ec933833281))
* Aurora animation ignoring light theme background color ([c1dc019](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c1dc019c8b2819e13da42ad1c2648740a09279d1))
* replace individual light theme overrides with CSS variable swap ([9ac00c9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/9ac00c94a6805577d6ac71e83fae5032217a31c9))
* use dynamic champagne variables for light theme palette swap ([ecd912b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ecd912b16a63b692b45361cfa53e7cacb0cb3e4f))
* use theme surface and background colors for Aurora animation ([a91e055](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a91e0555979540f50ca1afef5ee8c91b162c4a7f))
- add theme toggle to desktop header and sync theme across components ([bf00d37](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bf00d37b4af799130e3dd8cd2c083ec933833281))
- Aurora animation ignoring light theme background color ([c1dc019](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c1dc019c8b2819e13da42ad1c2648740a09279d1))
- replace individual light theme overrides with CSS variable swap ([9ac00c9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/9ac00c94a6805577d6ac71e83fae5032217a31c9))
- use dynamic champagne variables for light theme palette swap ([ecd912b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ecd912b16a63b692b45361cfa53e7cacb0cb3e4f))
- use theme surface and background colors for Aurora animation ([a91e055](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a91e0555979540f50ca1afef5ee8c91b162c4a7f))
## [1.6.1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.6.0...v1.6.1) (2026-02-04)
### Bug Fixes
* add fallback recovery for Telegram popup callback not firing ([7ac7db4](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7ac7db4ddb2950216334be01db37f185594bea6e))
* add HMR guard to prevent ConcurrentCallError on SDK double-init ([bcbda17](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bcbda17220357815ee2df269e293db0fecee7bd3))
* get fresh Telegram WebApp reference on each popup call ([792fb1e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/792fb1ed8a1cc4d4a5350556308e00e8cad5313a))
* prevent duplicate Telegram popup opening ([71647eb](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/71647ebc8795fc57f958d4fdedfb2f9c0b23837e))
* prevent popup cascade when Telegram callback doesn't fire ([2d00a5c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2d00a5c21fff339f229d8fe2001a898d15722cdd))
* resolve SDK v3 mount errors, back button and fullscreen not working ([61e3910](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/61e3910981e401fbc0b968615307e5101f6f96e9))
* use direct dialog.popup call instead of useDestructiveConfirm ([ef77276](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ef77276246fdb60255f625289542584b83c93fcc))
- add fallback recovery for Telegram popup callback not firing ([7ac7db4](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7ac7db4ddb2950216334be01db37f185594bea6e))
- add HMR guard to prevent ConcurrentCallError on SDK double-init ([bcbda17](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bcbda17220357815ee2df269e293db0fecee7bd3))
- get fresh Telegram WebApp reference on each popup call ([792fb1e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/792fb1ed8a1cc4d4a5350556308e00e8cad5313a))
- prevent duplicate Telegram popup opening ([71647eb](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/71647ebc8795fc57f958d4fdedfb2f9c0b23837e))
- prevent popup cascade when Telegram callback doesn't fire ([2d00a5c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2d00a5c21fff339f229d8fe2001a898d15722cdd))
- resolve SDK v3 mount errors, back button and fullscreen not working ([61e3910](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/61e3910981e401fbc0b968615307e5101f6f96e9))
- use direct dialog.popup call instead of useDestructiveConfirm ([ef77276](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ef77276246fdb60255f625289542584b83c93fcc))
## [1.6.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.5.0...v1.6.0) (2026-02-04)
### Features
* add animated MovingGradient background ([24781f3](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/24781f32ec0b7889ddb4a88d994d91b6d8593dec))
* add autocomplete to settings search ([e5096d5](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e5096d571ff0b9ea3fb9c08c1cbece46a1ece656))
* add locales for user search in promo offer sending ([0c9d092](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/0c9d09280c600e03c8cd8b7f51c8eb27c664c22c))
* add Telegram/Email channel selection to broadcast create page ([0773afd](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/0773afdf6e37f0b8da679a176048764110795919))
* add useNotify hook for unified notifications ([6f4d1ef](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/6f4d1ef08587a671a3060c304bc8603df5d9a17e))
* add user search autocomplete for promo offer sending ([fc92267](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/fc922671d2fe2de7e4961205158c8e7e3404020a))
* extract promocodes and promo groups into separate pages ([a96ddde](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a96ddde314d938c2ee2e7c8fe7eaab84007060df))
* improve tariff builder UI ([e19767a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e19767af82ed84c80f1e5cb0e9535962d360fd54))
* Linear-style UI redesign with improved mobile experience ([b953ee0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/b953ee0b8c79c6340eca0467d65fee22d362875e))
* migrate to [@tma](https://github.com/tma).js/sdk-react for Telegram Mini App ([edb5be0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/edb5be09ae372b6ee2985484518bdf76d87b89e4))
* move campaign statistics to dedicated page ([1027deb](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/1027deb134d58f72f40b2f18878e8700683a4c86))
* move ticket settings to dedicated page ([ead4606](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ead4606bb59e59c50445c7b7198abf42c54e1326))
* redesign fortune wheel UI and add to mobile nav ([7e2802c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7e2802c5b5ee4bae1cf8d07009056e9c66688197))
* redesign fortune wheel with improved UX ([494285b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/494285bcbf99d8f74873dcf571b0a780e968100b))
* replace broadcast creation modal with dedicated page ([175516e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/175516ec9bc85575483fa7223838e5f52e4cfe7b))
* replace campaign creation modal with dedicated page ([bce4d94](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bce4d94229c81d67a7621faccf9a69bd6b61d5e9))
* replace MovingGradient with Aurora WebGL background ([cffef41](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/cffef41f634f940da07645461420f32554e6da9d))
* replace tariff creation modal with dedicated page ([dc17695](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/dc1769520612286bc2da4bd25c23a60ab38792f3))
* scroll to tariffs section when clicking discount badge ([3613294](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/3613294a7869e6866d3f3d5d2dccde4102e05b9f))
- add animated MovingGradient background ([24781f3](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/24781f32ec0b7889ddb4a88d994d91b6d8593dec))
- add autocomplete to settings search ([e5096d5](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e5096d571ff0b9ea3fb9c08c1cbece46a1ece656))
- add locales for user search in promo offer sending ([0c9d092](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/0c9d09280c600e03c8cd8b7f51c8eb27c664c22c))
- add Telegram/Email channel selection to broadcast create page ([0773afd](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/0773afdf6e37f0b8da679a176048764110795919))
- add useNotify hook for unified notifications ([6f4d1ef](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/6f4d1ef08587a671a3060c304bc8603df5d9a17e))
- add user search autocomplete for promo offer sending ([fc92267](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/fc922671d2fe2de7e4961205158c8e7e3404020a))
- extract promocodes and promo groups into separate pages ([a96ddde](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a96ddde314d938c2ee2e7c8fe7eaab84007060df))
- improve tariff builder UI ([e19767a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e19767af82ed84c80f1e5cb0e9535962d360fd54))
- Linear-style UI redesign with improved mobile experience ([b953ee0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/b953ee0b8c79c6340eca0467d65fee22d362875e))
- migrate to [@tma](https://github.com/tma).js/sdk-react for Telegram Mini App ([edb5be0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/edb5be09ae372b6ee2985484518bdf76d87b89e4))
- move campaign statistics to dedicated page ([1027deb](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/1027deb134d58f72f40b2f18878e8700683a4c86))
- move ticket settings to dedicated page ([ead4606](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ead4606bb59e59c50445c7b7198abf42c54e1326))
- redesign fortune wheel UI and add to mobile nav ([7e2802c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7e2802c5b5ee4bae1cf8d07009056e9c66688197))
- redesign fortune wheel with improved UX ([494285b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/494285bcbf99d8f74873dcf571b0a780e968100b))
- replace broadcast creation modal with dedicated page ([175516e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/175516ec9bc85575483fa7223838e5f52e4cfe7b))
- replace campaign creation modal with dedicated page ([bce4d94](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bce4d94229c81d67a7621faccf9a69bd6b61d5e9))
- replace MovingGradient with Aurora WebGL background ([cffef41](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/cffef41f634f940da07645461420f32554e6da9d))
- replace tariff creation modal with dedicated page ([dc17695](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/dc1769520612286bc2da4bd25c23a60ab38792f3))
- scroll to tariffs section when clicking discount badge ([3613294](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/3613294a7869e6866d3f3d5d2dccde4102e05b9f))
### Bug Fixes
* add full i18n support for RemnaWave section and improve sync UI ([ed86dfa](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ed86dfa8bd0c680f842bc17ef7663ee1d266ee11))
* add missing i18n keys for broadcast detail page ([c60a242](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c60a242f1da4b03d44d56485d83313755a5a0c8e))
* add placeholders to all tariff form number inputs ([8cd95b8](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8cd95b84fb703104d436d4ce417d3ab57aa66f9f))
* add Russian translation for device limit reduction reason ([e884860](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e884860ab86b6cbcdb9db2858f47bd3813a18740))
* add Telegram header padding for Android fullscreen mode ([093c9f2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/093c9f28935133cd300bf4c481d5c798011ecbfe))
* AdminTariffCreate back button and daily tariff colors ([d623cd4](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d623cd41e9e9e15733c68b9640f0296f37043468))
* allow clearing number inputs and add validation ([47e28ee](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/47e28ee78fd417db86ad4a026f5983f68f0e1c76))
* Aurora uses theme colors from API with blur overlay ([55ae55f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/55ae55f4af1050207ceac6db992f6bf66ce4b77b))
* disable Telegram swipe-to-close globally to prevent accidental app closures ([9b0be28](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/9b0be280d292179fb6e9f219c21d18ce1ae7e5fc))
* disable Telegram vertical swipes during drag operations ([8deca2f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8deca2fa5bf1da2ba76191616d4a1638d1d79f9d))
* handle unmounted SDK components gracefully ([baa57b9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/baa57b907ecb3ada3623eafcffbc070a56e1915c))
* improve admin user detail tabs scroll and sync buttons design ([45dac03](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/45dac039f9e77f55fefe6ea1e761edd037bd13d8))
* improve header layout for mobile - stack button below title ([643d4fd](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/643d4fd3af891fc9a9e27501dae715f91242f200))
* improve mobile layout for bandwidth and add pluralization support ([e9af285](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e9af285dadeeb0ce951940be46e07561f61844d9))
* improve tariff delete button visibility ([5b30f24](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5b30f24e7e3bd672464a0314433d8649267e7828))
* improve Toast visibility and allow tariff deletion with subscriptions ([36cc01c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/36cc01ca7e2489d603d96673f93237648832223e))
* make Aurora colors vibrant and increase speed ([851e6a3](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/851e6a353bbf5c48c7cdf36ba1d9fd821c775aff))
* promo offer button mobile layout ([1d4a99c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/1d4a99c47432128402d213a3be3cbd9f316e6171))
* remove dark backgrounds causing black rectangles ([2926a5a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2926a5a89c522b2133b9f56eef950031c0f7f2c1))
* remove page transition animations to prevent flashing ([dda8323](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/dda8323b452bd3d3054d5bbdd607ce5c7a27a6a4))
* remove quick actions section and optimize build chunks ([de613d9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/de613d909d64b4e1853032447df671939ddad9b2))
* remove small discount badge and improve large badge UX ([822b9a6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/822b9a6265e7c9b14c4ef630e80ee81ef9597496))
* restore page animations, improve checkbox visibility ([5ad5e8d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5ad5e8d3657b8074305fd1c8b94cebd5c6cb4af2))
* revert to native Telegram WebApp API, remove SDK usage ([6f8bc4f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/6f8bc4fca592f074d4705069a48bbaef3e5a105d))
* scroll to start of tariffs section and wait for data to load ([c815ac2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c815ac29ea281056908f39dfb6a390b3b201377b))
* UI improvements - reduce Android header, hide mobile scrollbar, disable animations in Telegram, consistent menu overlay ([768b340](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/768b340c35c5f145940bb9966e2e6713039be32f))
* unify card styles across the project ([4a25d8d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4a25d8df03b26f25800f882e46223bab64873b73))
* use native Telegram popup for email preview restriction ([7aeb47f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7aeb47f583056d651b90db4ed7cad2d2a2aef3b1))
* use pill-style tabs in admin user detail page ([09584fc](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/09584fc901d778453fbda437d643f4bd9c4321e3))
* use RemnaWave icon in admin panel menu ([4034b4d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4034b4db3932cab273636bb54bf796aff6de712c))
* use single shared WebSocket connection and optimize build chunks ([f6854c6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f6854c6c3aef3ff46d7839dfe94d2ec6bf4d7d64))
* wrap all SDK isSupported() calls in try-catch ([e5ea09d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e5ea09dd3a13ec47ca65de93c6f5858c1fadb135))
- add full i18n support for RemnaWave section and improve sync UI ([ed86dfa](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ed86dfa8bd0c680f842bc17ef7663ee1d266ee11))
- add missing i18n keys for broadcast detail page ([c60a242](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c60a242f1da4b03d44d56485d83313755a5a0c8e))
- add placeholders to all tariff form number inputs ([8cd95b8](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8cd95b84fb703104d436d4ce417d3ab57aa66f9f))
- add Russian translation for device limit reduction reason ([e884860](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e884860ab86b6cbcdb9db2858f47bd3813a18740))
- add Telegram header padding for Android fullscreen mode ([093c9f2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/093c9f28935133cd300bf4c481d5c798011ecbfe))
- AdminTariffCreate back button and daily tariff colors ([d623cd4](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d623cd41e9e9e15733c68b9640f0296f37043468))
- allow clearing number inputs and add validation ([47e28ee](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/47e28ee78fd417db86ad4a026f5983f68f0e1c76))
- Aurora uses theme colors from API with blur overlay ([55ae55f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/55ae55f4af1050207ceac6db992f6bf66ce4b77b))
- disable Telegram swipe-to-close globally to prevent accidental app closures ([9b0be28](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/9b0be280d292179fb6e9f219c21d18ce1ae7e5fc))
- disable Telegram vertical swipes during drag operations ([8deca2f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8deca2fa5bf1da2ba76191616d4a1638d1d79f9d))
- handle unmounted SDK components gracefully ([baa57b9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/baa57b907ecb3ada3623eafcffbc070a56e1915c))
- improve admin user detail tabs scroll and sync buttons design ([45dac03](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/45dac039f9e77f55fefe6ea1e761edd037bd13d8))
- improve header layout for mobile - stack button below title ([643d4fd](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/643d4fd3af891fc9a9e27501dae715f91242f200))
- improve mobile layout for bandwidth and add pluralization support ([e9af285](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e9af285dadeeb0ce951940be46e07561f61844d9))
- improve tariff delete button visibility ([5b30f24](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5b30f24e7e3bd672464a0314433d8649267e7828))
- improve Toast visibility and allow tariff deletion with subscriptions ([36cc01c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/36cc01ca7e2489d603d96673f93237648832223e))
- make Aurora colors vibrant and increase speed ([851e6a3](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/851e6a353bbf5c48c7cdf36ba1d9fd821c775aff))
- promo offer button mobile layout ([1d4a99c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/1d4a99c47432128402d213a3be3cbd9f316e6171))
- remove dark backgrounds causing black rectangles ([2926a5a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2926a5a89c522b2133b9f56eef950031c0f7f2c1))
- remove page transition animations to prevent flashing ([dda8323](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/dda8323b452bd3d3054d5bbdd607ce5c7a27a6a4))
- remove quick actions section and optimize build chunks ([de613d9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/de613d909d64b4e1853032447df671939ddad9b2))
- remove small discount badge and improve large badge UX ([822b9a6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/822b9a6265e7c9b14c4ef630e80ee81ef9597496))
- restore page animations, improve checkbox visibility ([5ad5e8d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5ad5e8d3657b8074305fd1c8b94cebd5c6cb4af2))
- revert to native Telegram WebApp API, remove SDK usage ([6f8bc4f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/6f8bc4fca592f074d4705069a48bbaef3e5a105d))
- scroll to start of tariffs section and wait for data to load ([c815ac2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c815ac29ea281056908f39dfb6a390b3b201377b))
- UI improvements - reduce Android header, hide mobile scrollbar, disable animations in Telegram, consistent menu overlay ([768b340](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/768b340c35c5f145940bb9966e2e6713039be32f))
- unify card styles across the project ([4a25d8d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4a25d8df03b26f25800f882e46223bab64873b73))
- use native Telegram popup for email preview restriction ([7aeb47f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7aeb47f583056d651b90db4ed7cad2d2a2aef3b1))
- use pill-style tabs in admin user detail page ([09584fc](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/09584fc901d778453fbda437d643f4bd9c4321e3))
- use RemnaWave icon in admin panel menu ([4034b4d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4034b4db3932cab273636bb54bf796aff6de712c))
- use single shared WebSocket connection and optimize build chunks ([f6854c6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f6854c6c3aef3ff46d7839dfe94d2ec6bf4d7d64))
- wrap all SDK isSupported() calls in try-catch ([e5ea09d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e5ea09dd3a13ec47ca65de93c6f5858c1fadb135))

View File

@@ -22,15 +22,7 @@ services:
# Внутри контейнера nginx слушает на порту 80
- '${CABINET_PORT:-3020}:80'
healthcheck:
test:
[
'CMD',
'wget',
'--no-verbose',
'--tries=1',
'--spider',
'http://localhost:80/',
]
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:80/']
interval: 30s
timeout: 3s
retries: 3

View File

@@ -1,9 +1,9 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import eslintConfigPrettier from 'eslint-config-prettier'
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
import eslintConfigPrettier from 'eslint-config-prettier';
export default tseslint.config(
{
@@ -11,10 +11,7 @@ export default tseslint.config(
},
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
...tseslint.configs.recommended,
],
extends: [js.configs.recommended, ...tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
@@ -31,10 +28,7 @@ export default tseslint.config(
'react-hooks/refs': 'off',
'react-hooks/purity': 'off',
'react-hooks/variables': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-empty': 'warn',
@@ -45,4 +39,4 @@ export default tseslint.config(
},
},
eslintConfigPrettier,
)
);

View File

@@ -1,9 +1,12 @@
<!DOCTYPE html>
<!doctype html>
<html lang="ru" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:," />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta name="theme-color" content="#0a0f1a" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
@@ -11,7 +14,9 @@
<title>Loading...</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
body { background-color: #0a0f1a; }
body {
background-color: #0a0f1a;
}
</style>
</head>
<body>

854
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lottiefiles/dotlottie-react": "^0.8.0",
"@kastov/cryptohapp": "^1.1.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -33,40 +33,42 @@
"@radix-ui/react-visually-hidden": "^1.2.4",
"@tanstack/react-query": "^5.8.0",
"@telegram-apps/sdk-react": "^3.3.9",
"autoprefixer": "^10.4.24",
"axios": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dompurify": "^3.3.1",
"framer-motion": "^12.29.2",
"i18next": "^23.7.0",
"i18next-browser-languagedetector": "^7.2.0",
"i18next": "^25.8.4",
"i18next-browser-languagedetector": "^8.2.0",
"jsencrypt": "^3.5.4",
"ogl": "^1.0.11",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^13.5.0",
"react-router-dom": "^6.30.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^16.5.4",
"react-router": "^7.13.0",
"tailwind-merge": "^3.4.0",
"zustand": "^4.4.0"
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/dompurify": "^3.0.5",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@types/node": "^25.2.1",
"@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.16",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"eslint-plugin-react-refresh": "^0.5.0",
"globals": "^17.1.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"postcss": "^8.4.31",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^3.3.5",
"tailwindcss": "^3.4.19",
"typescript": "^5.2.2",
"typescript-eslint": "^8.54.0",
"vite": "^7.3.1"

View File

@@ -3,4 +3,4 @@ export default {
tailwindcss: {},
autoprefixer: {},
},
}
};

View File

@@ -1,10 +1,14 @@
import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { Routes, Route, Navigate, useLocation } from 'react-router';
import { useAuthStore } from './store/auth';
import { useBlockingStore } from './store/blocking';
import Layout from './components/layout/Layout';
import PageLoader from './components/common/PageLoader';
import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking';
import {
MaintenanceScreen,
ChannelSubscriptionScreen,
BlacklistedScreen,
} from './components/blocking';
import { saveReturnUrl } from './utils/token';
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
// Auth pages - load immediately (small)
@@ -26,6 +30,7 @@ const Contests = lazy(() => import('./pages/Contests'));
const Polls = lazy(() => import('./pages/Polls'));
const Info = lazy(() => import('./pages/Info'));
const Wheel = lazy(() => import('./pages/Wheel'));
const Connection = lazy(() => import('./pages/Connection'));
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
@@ -121,6 +126,10 @@ function BlockingOverlay() {
return <ChannelSubscriptionScreen />;
}
if (blockingType === 'blacklisted') {
return <BlacklistedScreen />;
}
return null;
}
@@ -262,6 +271,16 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/connection"
element={
<ProtectedRoute>
<LazyPage>
<Connection />
</LazyPage>
</ProtectedRoute>
}
/>
{/* Admin routes */}
<Route

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useCallback } from 'react';
import { BrowserRouter, useLocation, useNavigate } from 'react-router-dom';
import { BrowserRouter, useLocation, useNavigate } from 'react-router';
import {
showBackButton,
hideBackButton,
@@ -7,6 +7,7 @@ import {
offBackButtonClick,
} from '@telegram-apps/sdk-react';
import App from './App';
import { ErrorBoundary } from './components/ErrorBoundary';
import { PlatformProvider } from './platform/PlatformProvider';
import { ThemeColorsProvider } from './providers/ThemeColorsProvider';
import { WebSocketProvider } from './providers/WebSocketProvider';
@@ -66,6 +67,7 @@ export function AppWithNavigator() {
return (
<BrowserRouter>
{isTelegram && <TelegramBackButton />}
<ErrorBoundary level="page">
<PlatformProvider>
<ThemeColorsProvider>
<TooltipProvider>
@@ -77,6 +79,7 @@ export function AppWithNavigator() {
</TooltipProvider>
</ThemeColorsProvider>
</PlatformProvider>
</ErrorBoundary>
</BrowserRouter>
);
}

View File

@@ -8,133 +8,7 @@ export interface LocalizedText {
fr?: string;
}
export interface AppButton {
id?: string; // Unique identifier for React key (client-side only)
buttonLink: string;
buttonText: LocalizedText;
}
export interface AppStep {
description: LocalizedText;
buttons?: AppButton[];
title?: LocalizedText;
}
export interface AppDefinition {
id: string;
name: string;
isFeatured: boolean;
urlScheme: string;
isNeedBase64Encoding?: boolean;
installationStep: AppStep;
addSubscriptionStep: AppStep;
connectAndUseStep: AppStep;
additionalBeforeAddSubscriptionStep?: AppStep;
additionalAfterAddSubscriptionStep?: AppStep;
}
export interface AppConfigBranding {
name: string;
logoUrl: string;
supportUrl: string;
}
export interface AppConfigConfig {
additionalLocales: string[];
branding: AppConfigBranding;
}
export interface AppConfigResponse {
config: AppConfigConfig;
platforms: Record<string, AppDefinition[]>;
}
export const adminAppsApi = {
// Get full app config
getConfig: async (): Promise<AppConfigResponse> => {
const response = await apiClient.get<AppConfigResponse>('/cabinet/admin/apps');
return response.data;
},
// Get available platforms
getPlatforms: async (): Promise<string[]> => {
const response = await apiClient.get<string[]>('/cabinet/admin/apps/platforms');
return response.data;
},
// Get apps for a platform
getPlatformApps: async (platform: string): Promise<AppDefinition[]> => {
const response = await apiClient.get<AppDefinition[]>(
`/cabinet/admin/apps/platforms/${platform}`,
);
return response.data;
},
// Create a new app
createApp: async (platform: string, app: AppDefinition): Promise<AppDefinition> => {
const response = await apiClient.post<AppDefinition>(
`/cabinet/admin/apps/platforms/${platform}`,
{
platform,
app,
},
);
return response.data;
},
// Update an app
updateApp: async (
platform: string,
appId: string,
app: AppDefinition,
): Promise<AppDefinition> => {
const response = await apiClient.put<AppDefinition>(
`/cabinet/admin/apps/platforms/${platform}/${appId}`,
{
app,
},
);
return response.data;
},
// Delete an app
deleteApp: async (platform: string, appId: string): Promise<void> => {
await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`);
},
// Reorder apps
reorderApps: async (platform: string, appIds: string[]): Promise<void> => {
await apiClient.post(`/cabinet/admin/apps/platforms/${platform}/reorder`, {
app_ids: appIds,
});
},
// Copy app to another platform
copyApp: async (
platform: string,
appId: string,
targetPlatform: string,
): Promise<{ new_id: string }> => {
const response = await apiClient.post<{ new_id: string; target_platform: string }>(
`/cabinet/admin/apps/platforms/${platform}/copy/${appId}?target_platform=${targetPlatform}`,
);
return response.data;
},
// Get branding
getBranding: async (): Promise<AppConfigBranding> => {
const response = await apiClient.get<AppConfigBranding>('/cabinet/admin/apps/branding');
return response.data;
},
// Update branding
updateBranding: async (branding: AppConfigBranding): Promise<AppConfigBranding> => {
const response = await apiClient.put<AppConfigBranding>('/cabinet/admin/apps/branding', {
branding,
});
return response.data;
},
// Get RemnaWave config status
getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => {
const response = await apiClient.get<{ enabled: boolean; config_uuid: string | null }>(
@@ -181,6 +55,8 @@ export const adminAppsApi = {
export interface RemnawaveButton {
url: string;
text: LocalizedText;
type?: 'external' | 'subscriptionLink' | 'copyButton';
svgIconKey?: string;
}
export interface RemnawaveBlock {
@@ -196,10 +72,12 @@ export interface RemnawaveApp {
featured?: boolean;
urlScheme?: string;
isNeedBase64Encoding?: boolean;
svgIconKey?: string;
blocks: RemnawaveBlock[];
}
export interface RemnawavePlatform {
svgIconKey?: string;
apps: RemnawaveApp[];
}
@@ -236,332 +114,3 @@ export interface RemnawaveConfig {
baseTranslations?: RemnawaveBaseTranslations;
brandingSettings?: RemnawaveBrandingSettings;
}
// ============== Converter Functions ==============
const emptyLocalizedText = (): LocalizedText => ({
en: '',
ru: '',
zh: '',
fa: '',
fr: '',
});
// Convert Cabinet button to RemnaWave button
const cabinetButtonToRemnawave = (button: AppButton): RemnawaveButton => ({
url: button.buttonLink,
text: { ...emptyLocalizedText(), ...button.buttonText },
});
// Convert RemnaWave button to Cabinet button
const remnawaveButtonToCabinet = (button: RemnawaveButton): AppButton => ({
buttonLink: button.url,
buttonText: {
en: button.text.en || '',
ru: button.text.ru || '',
zh: button.text.zh,
fa: button.text.fa,
fr: button.text.fr,
},
});
// Convert Cabinet app to RemnaWave app format
export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
const blocks: RemnawaveBlock[] = [];
// Block 1: Installation
blocks.push({
title: { ...emptyLocalizedText(), en: 'Install App', ru: 'Установка приложения' },
description: { ...emptyLocalizedText(), ...app.installationStep.description },
buttons: app.installationStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'download',
svgIconColor: '#3B82F6',
});
// Block 2 (optional): Additional before subscription
if (
app.additionalBeforeAddSubscriptionStep?.description?.en ||
app.additionalBeforeAddSubscriptionStep?.description?.ru
) {
blocks.push({
title: app.additionalBeforeAddSubscriptionStep.title || {
...emptyLocalizedText(),
en: 'Preparation',
ru: 'Подготовка',
},
description: {
...emptyLocalizedText(),
...app.additionalBeforeAddSubscriptionStep.description,
},
buttons: app.additionalBeforeAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'settings',
svgIconColor: '#8B5CF6',
});
}
// Block 3: Add subscription
blocks.push({
title: { ...emptyLocalizedText(), en: 'Add Subscription', ru: 'Добавить подписку' },
description: { ...emptyLocalizedText(), ...app.addSubscriptionStep.description },
buttons: app.addSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'plus',
svgIconColor: '#10B981',
});
// Block 4 (optional): Additional after subscription
if (
app.additionalAfterAddSubscriptionStep?.description?.en ||
app.additionalAfterAddSubscriptionStep?.description?.ru
) {
blocks.push({
title: app.additionalAfterAddSubscriptionStep.title || {
...emptyLocalizedText(),
en: 'Configuration',
ru: 'Настройка',
},
description: {
...emptyLocalizedText(),
...app.additionalAfterAddSubscriptionStep.description,
},
buttons: app.additionalAfterAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'settings',
svgIconColor: '#F59E0B',
});
}
// Block 5: Connect and use
blocks.push({
title: { ...emptyLocalizedText(), en: 'Connect and Use', ru: 'Подключение и использование' },
description: { ...emptyLocalizedText(), ...app.connectAndUseStep.description },
buttons: app.connectAndUseStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'check',
svgIconColor: '#22C55E',
});
return {
name: app.name,
featured: app.isFeatured,
urlScheme: app.urlScheme,
isNeedBase64Encoding: app.isNeedBase64Encoding,
blocks,
};
};
// Convert RemnaWave app to Cabinet app format
export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppDefinition => {
const blocks = app.blocks || [];
// Default empty step
const emptyStep = (): AppStep => ({
description: emptyLocalizedText(),
buttons: [],
});
// Try to identify blocks by their titles or position
let installationBlock: RemnawaveBlock | undefined;
let subscriptionBlock: RemnawaveBlock | undefined;
let connectBlock: RemnawaveBlock | undefined;
let beforeSubBlock: RemnawaveBlock | undefined;
let afterSubBlock: RemnawaveBlock | undefined;
// First pass: try to identify by title keywords
for (const block of blocks) {
const enTitle = (block.title?.en || '').toLowerCase();
const ruTitle = (block.title?.ru || '').toLowerCase();
if (enTitle.includes('install') || ruTitle.includes('установ') || ruTitle.includes('скачай')) {
if (!installationBlock) installationBlock = block;
} else if (
enTitle.includes('subscription') ||
enTitle.includes('add') ||
ruTitle.includes('подписк') ||
ruTitle.includes('добав')
) {
if (!subscriptionBlock) subscriptionBlock = block;
} else if (
enTitle.includes('connect') ||
enTitle.includes('use') ||
ruTitle.includes('подключ') ||
ruTitle.includes('использ')
) {
if (!connectBlock) connectBlock = block;
}
}
// Second pass: assign remaining blocks by position if not found by title
if (!installationBlock && blocks.length > 0) {
installationBlock = blocks[0];
}
if (!subscriptionBlock && blocks.length > 1) {
subscriptionBlock = blocks.find((b) => b !== installationBlock) || blocks[1];
}
if (!connectBlock && blocks.length > 2) {
connectBlock =
blocks.find((b) => b !== installationBlock && b !== subscriptionBlock) ||
blocks[blocks.length - 1];
}
// Assign additional blocks
const additionalBlocks = blocks.filter(
(b) => b !== installationBlock && b !== subscriptionBlock && b !== connectBlock,
);
if (additionalBlocks.length >= 1) {
// Check if block appears before subscription
const subIndex = blocks.indexOf(subscriptionBlock!);
const firstAdditionalIndex = blocks.indexOf(additionalBlocks[0]);
if (firstAdditionalIndex < subIndex) {
beforeSubBlock = additionalBlocks[0];
if (additionalBlocks.length >= 2) {
afterSubBlock = additionalBlocks[1];
}
} else {
afterSubBlock = additionalBlocks[0];
}
}
// Convert block to cabinet step
const blockToStep = (block: RemnawaveBlock | undefined): AppStep => {
if (!block) return emptyStep();
return {
description: {
en: block.description?.en || '',
ru: block.description?.ru || '',
zh: block.description?.zh,
fa: block.description?.fa,
fr: block.description?.fr,
},
title: block.title
? {
en: block.title.en || '',
ru: block.title.ru || '',
zh: block.title.zh,
fa: block.title.fa,
fr: block.title.fr,
}
: undefined,
buttons: block.buttons?.map(remnawaveButtonToCabinet),
};
};
return {
id: `${app.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${platform}-${Date.now()}`,
name: app.name,
isFeatured: app.featured || false,
urlScheme: app.urlScheme || '',
isNeedBase64Encoding: app.isNeedBase64Encoding,
installationStep: blockToStep(installationBlock),
addSubscriptionStep: blockToStep(subscriptionBlock),
connectAndUseStep: blockToStep(connectBlock),
additionalBeforeAddSubscriptionStep: beforeSubBlock ? blockToStep(beforeSubBlock) : undefined,
additionalAfterAddSubscriptionStep: afterSubBlock ? blockToStep(afterSubBlock) : undefined,
};
};
// Export all apps from cabinet to RemnaWave format
export const exportToRemnawaveFormat = (
platformApps: Record<string, AppDefinition[]>,
branding?: AppConfigBranding,
): RemnawaveConfig => {
const platforms: Record<string, RemnawavePlatform> = {};
for (const [platform, apps] of Object.entries(platformApps)) {
platforms[platform] = {
apps: apps.map(cabinetAppToRemnawave),
};
}
return {
platforms,
svgLibrary: {
download: {
svgString:
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>',
},
plus: {
svgString:
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>',
},
check: {
svgString:
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>',
},
settings: {
svgString:
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>',
},
},
baseSettings: {
isShowTutorialButton: false,
tutorialUrl: '',
},
baseTranslations: {
installApp: {
en: 'Install App',
ru: 'Установить приложение',
zh: '安装应用',
fa: 'نصب برنامه',
fr: "Installer l'application",
},
addSubscription: {
en: 'Add Subscription',
ru: 'Добавить подписку',
zh: '添加订阅',
fa: 'اضافه کردن اشتراک',
fr: 'Ajouter un abonnement',
},
connectAndUse: {
en: 'Connect and Use',
ru: 'Подключиться и использовать',
zh: '连接并使用',
fa: 'اتصال و استفاده',
fr: 'Connecter et utiliser',
},
copyLink: {
en: 'Copy Link',
ru: 'Скопировать ссылку',
zh: '复制链接',
fa: 'کپی لینک',
fr: 'Copier le lien',
},
openApp: {
en: 'Open App',
ru: 'Открыть приложение',
zh: '打开应用',
fa: 'باز کردن برنامه',
fr: "Ouvrir l'application",
},
tutorial: { en: 'Tutorial', ru: 'Инструкция', zh: '教程', fa: 'آموزش', fr: 'Tutoriel' },
close: { en: 'Close', ru: 'Закрыть', zh: '关闭', fa: 'بستن', fr: 'Fermer' },
},
brandingSettings: branding
? {
name: branding.name,
logoUrl: branding.logoUrl,
supportUrl: branding.supportUrl,
}
: undefined,
};
};
// Import apps from RemnaWave format to cabinet
export const importFromRemnawaveFormat = (
config: RemnawaveConfig,
): { platformApps: Record<string, AppDefinition[]>; branding?: AppConfigBranding } => {
const platformApps: Record<string, AppDefinition[]> = {};
for (const [platform, platformData] of Object.entries(config.platforms || {})) {
platformApps[platform] = (platformData.apps || []).map((app) =>
remnawaveAppToCabinet(app, platform),
);
}
const branding = config.brandingSettings
? {
name: config.brandingSettings.name,
logoUrl: config.brandingSettings.logoUrl,
supportUrl: config.brandingSettings.supportUrl,
}
: undefined;
return { platformApps, branding };
};

View File

@@ -7,6 +7,7 @@ import {
safeRedirectToLogin,
} from '../utils/token';
import { useBlockingStore } from '../store/blocking';
import { API } from '../config/constants';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
@@ -57,13 +58,34 @@ const getTelegramInitData = (): string | null => {
export const apiClient = axios.create({
baseURL: API_BASE_URL,
timeout: API.TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
},
});
// Auth endpoints that don't need Bearer token or token refresh
const AUTH_ENDPOINTS = [
'/cabinet/auth/telegram',
'/cabinet/auth/telegram/widget',
'/cabinet/auth/email/login',
'/cabinet/auth/email/register',
'/cabinet/auth/email/verify',
'/cabinet/auth/refresh',
'/cabinet/auth/password/forgot',
'/cabinet/auth/password/reset',
];
function isAuthEndpoint(url: string | undefined): boolean {
if (!url) return false;
return AUTH_ENDPOINTS.some((endpoint) => url.includes(endpoint));
}
// Request interceptor - add auth token with expiration check
apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
// Skip token refresh and Bearer header for auth endpoints
// These endpoints authenticate via init_data/credentials, not Bearer tokens
if (!isAuthEndpoint(config.url)) {
let token = tokenStorage.getAccessToken();
// Проверяем срок действия токена перед запросом
@@ -83,6 +105,7 @@ apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) =>
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
}
const telegramInitData = getTelegramInitData();
if (telegramInitData && config.headers) {
@@ -111,6 +134,11 @@ export interface ChannelSubscriptionError {
channel_link?: string;
}
export interface BlacklistedError {
code: 'blacklisted';
message: string;
}
export function isMaintenanceError(
error: unknown,
): error is { response: { status: 503; data: { detail: MaintenanceError } } } {
@@ -130,6 +158,14 @@ export function isChannelSubscriptionError(
);
}
export function isBlacklistedError(
error: unknown,
): error is { response: { status: 403; data: { detail: BlacklistedError } } } {
if (!error || typeof error !== 'object') return false;
const err = error as AxiosError<{ detail: BlacklistedError }>;
return err.response?.status === 403 && err.response?.data?.detail?.code === 'blacklisted';
}
// Response interceptor - handle 401, 503 (maintenance), 403 (channel subscription)
apiClient.interceptors.response.use(
(response) => response,
@@ -156,6 +192,15 @@ apiClient.interceptors.response.use(
return Promise.reject(error);
}
// Handle blacklisted user (403)
if (isBlacklistedError(error)) {
const detail = (error.response?.data as { detail: BlacklistedError }).detail;
useBlockingStore.getState().setBlacklisted({
message: detail.message,
});
return Promise.reject(error);
}
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
if (error.response?.status === 401 && !originalRequest._retry) {
// Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента

View File

@@ -13,6 +13,7 @@ export interface ContestInfo {
export interface ContestGameData {
round_id: number;
game_type: string;
// Dynamic game data from backend - shape depends on game_type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
game_data: Record<string, any>;
instructions: string;

View File

@@ -10,3 +10,22 @@ export { promoApi } from './promo';
export { notificationsApi } from './notifications';
export { infoApi } from './info';
export { adminSettingsApi } from './adminSettings';
export { adminApi, statsApi } from './admin';
export { adminAppsApi } from './adminApps';
export { adminBroadcastsApi } from './adminBroadcasts';
export { adminEmailTemplatesApi } from './adminEmailTemplates';
export { adminPaymentMethodsApi } from './adminPaymentMethods';
export { adminPaymentsApi } from './adminPayments';
export { adminRemnawaveApi } from './adminRemnawave';
export { adminUsersApi } from './adminUsers';
export { banSystemApi } from './banSystem';
export { brandingApi } from './branding';
export { campaignsApi } from './campaigns';
export { currencyApi } from './currency';
export { promocodesApi } from './promocodes';
export { promoOffersApi } from './promoOffers';
export { serversApi } from './servers';
export { tariffsApi } from './tariffs';
export { themeColorsApi } from './themeColors';
export { ticketNotificationsApi } from './ticketNotifications';
export { wheelApi, adminWheelApi } from './wheel';

View File

@@ -1,44 +0,0 @@
import axios, { AxiosError } from 'axios';
export interface MiniappCreatePaymentPayload {
initData: string;
method: string;
amountKopeks?: number | null;
option?: string | null;
}
export interface MiniappCreatePaymentResponse {
payment_url: string;
amount_kopeks?: number;
extra?: Record<string, unknown>;
}
export const miniappApi = {
// Create payment inside Telegram Mini App (same flow as miniapp/index.html)
createPayment: async (
payload: MiniappCreatePaymentPayload,
): Promise<MiniappCreatePaymentResponse> => {
try {
const response = await axios.post<MiniappCreatePaymentResponse>(
'/miniapp/payments/create',
{
initData: payload.initData || '',
method: payload.method,
amountKopeks: payload.amountKopeks ?? null,
option: payload.option ?? null,
},
{
headers: { 'Content-Type': 'application/json' },
},
);
return response.data;
} catch (error) {
const axiosError = error as AxiosError<{ detail?: string; message?: string }>;
const message =
axiosError.response?.data?.detail ||
axiosError.response?.data?.message ||
'Failed to create payment';
throw new Error(message);
}
},
};

View File

@@ -9,6 +9,7 @@ export interface PromoOffer {
is_active: boolean;
is_claimed: boolean;
claimed_at: string | null;
// Dynamic extra data from backend
// eslint-disable-next-line @typescript-eslint/no-explicit-any
extra_data: Record<string, any> | null;
}

View File

@@ -31,6 +31,7 @@ export interface PromoOffer {
claimed_at: string | null;
is_active: boolean;
effect_type: string;
// Dynamic extra data from backend - shape depends on effect_type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
extra_data: Record<string, any>;
created_at: string;

View File

@@ -91,6 +91,11 @@ export const subscriptionApi = {
can_add?: number;
days_left?: number;
base_device_price_kopeks?: number;
// Discount fields (from promo group)
original_price_per_device_kopeks?: number;
base_total_price_kopeks?: number;
discount_percent?: number;
discount_kopeks?: number;
}> => {
const response = await apiClient.get('/cabinet/subscription/devices/price', {
params: { devices },
@@ -355,6 +360,10 @@ export const subscriptionApi = {
missing_amount_kopeks: number;
missing_amount_label: string;
is_upgrade: boolean;
// Discount fields (from promo group)
base_upgrade_cost_kopeks?: number;
discount_percent?: number;
discount_kopeks?: number;
}> => {
const response = await apiClient.post('/cabinet/subscription/tariff/switch/preview', {
tariff_id: tariffId,

View File

@@ -231,6 +231,11 @@ export const tariffsApi = {
return response.data;
},
// Update tariff display order
updateOrder: async (tariffIds: number[]): Promise<void> => {
await apiClient.put('/cabinet/admin/tariffs/order', { tariff_ids: tariffIds });
},
// Get available servers for selection
getAvailableServers: async (): Promise<ServerInfo[]> => {
const response = await apiClient.get('/cabinet/admin/tariffs/available-servers');

View File

@@ -3,9 +3,10 @@ import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { authApi } from '../api/auth';
import { isValidEmail } from '../utils/validation';
import { UI } from '../config/constants';
import { useAuthStore } from '../store/auth';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { useBackButton } from '@/platform';
import { useTelegramSDK } from '../hooks/useTelegramSDK';
// Icons
const CloseIcon = () => (
@@ -50,13 +51,13 @@ function useIsMobile() {
return isMobile;
}
const RESEND_COOLDOWN = 60; // seconds
const RESEND_COOLDOWN = UI.RESEND_COOLDOWN_SEC;
export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailModalProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { setUser } = useAuthStore();
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
const isMobileScreen = useIsMobile();
const emailInputRef = useRef<HTMLInputElement>(null);
@@ -88,9 +89,6 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleClose]);
// Telegram back button - using platform hook
useBackButton(handleClose);
// Scroll lock
useEffect(() => {
const scrollY = window.scrollY;
@@ -181,18 +179,13 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM
},
});
const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email.trim());
};
const handleSendCode = () => {
setError(null);
if (!newEmail.trim()) {
setError(t('profile.emailRequired'));
return;
}
if (!validateEmail(newEmail)) {
if (!isValidEmail(newEmail.trim())) {
setError(t('profile.invalidEmail'));
return;
}

View File

@@ -1,733 +0,0 @@
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { openLink as sdkOpenLink } from '@telegram-apps/sdk-react';
import { subscriptionApi } from '../api/subscription';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { useBackButton, useHaptic } from '@/platform';
import type { AppInfo, AppConfig, LocalizedText } from '../types';
interface ConnectionModalProps {
onClose: () => void;
}
// Icons
const CloseIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const CopyIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
);
const CheckIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
);
const LinkIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
);
const ChevronIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
);
const BackIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
);
// App icons
const HappIcon = () => (
<svg className="h-6 w-6" viewBox="0 0 50 50" fill="currentColor">
<path d="M22.3264 3H12.3611L9.44444 20.1525L21.3542 8.22034L22.3264 3Z" />
<path d="M10.9028 20.1525L22.8125 8.22034L20.8681 21.1469H28.4028L27.9167 21.6441L20.8681 28.8531H19.4097V30.5932L7.5 42.5254L10.9028 20.1525Z" />
<path d="M41.0417 8.22034L28.8889 20.1525L31.684 3H41.7708L41.0417 8.22034Z" />
<path d="M30.3472 20.1525L42.5 8.22034L38.6111 30.3446L26.9444 42.5254L29.0104 28.8531H22.3264L29.6181 21.1469H30.3472V20.1525Z" />
<path d="M40.0694 30.3446L28.4028 42.5254L27.9167 47H37.8819L40.0694 30.3446Z" />
<path d="M18.6806 47H8.47222L8.95833 42.5254L20.8681 30.5932L18.6806 47Z" />
</svg>
);
const ClashMetaIcon = () => (
<svg className="h-6 w-6" viewBox="0 0 50 50" fill="currentColor">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.99239 5.21742C4.0328 5.32232 3.19446 5.43999 3.12928 5.47886C2.94374 5.58955 2.96432 33.4961 3.14997 33.6449C3.2266 33.7062 4.44146 34.002 5.84976 34.3022C7.94234 34.7483 8.60505 34.8481 9.47521 34.8481C10.3607 34.8481 10.5706 34.8154 10.7219 34.6541C10.8859 34.479 10.9066 33.7222 10.9338 26.9143L10.9638 19.3685L11.2759 19.1094C11.6656 18.7859 12.1188 18.7789 12.5285 19.0899C12.702 19.2216 14.319 20.624 16.1219 22.2061C17.9247 23.7883 19.5136 25.1104 19.6527 25.144C19.7919 25.1777 20.3714 25.105 20.9406 24.9825C22.6144 24.6221 23.3346 24.5424 24.9233 24.5421C26.4082 24.5417 27.8618 24.71 29.2219 25.0398C29.6074 25.1333 30.0523 25.1784 30.2107 25.1399C30.369 25.1016 31.1086 24.5336 31.8543 23.8777C33.3462 22.5653 33.6461 22.3017 35.4359 20.7293C36.1082 20.1388 36.6831 19.6313 36.7137 19.6017C37.5681 18.7742 38.0857 18.6551 38.6132 19.1642L38.9383 19.478V34.5138L39.1856 34.6809C39.6343 34.9843 41.2534 34.9022 43.195 34.4775C44.1268 34.2737 45.2896 34.0291 45.779 33.9339C46.2927 33.8341 46.7276 33.687 46.8079 33.5861C47.0172 33.3228 47.0109 5.87708 46.8014 5.6005C46.6822 5.4431 46.2851 5.37063 44.605 5.1996C43.477 5.08482 42.2972 5.00505 41.983 5.02223L41.4121 5.05368L35.4898 10.261C27.3144 17.4495 27.7989 17.0418 27.5372 16.9533C27.4148 16.912 26.1045 16.8746 24.6253 16.8702C22.0674 16.8626 21.9233 16.8513 21.6777 16.6396C21.0693 16.115 17.2912 12.8028 14.5726 10.4108C12.9548 8.98729 10.9055 7.18761 10.0186 6.41134L8.40584 5L7.5715 5.01331C7.11256 5.02072 5.95198 5.11252 4.99239 5.21742Z"
/>
</svg>
);
const ShadowrocketIcon = () => (
<svg
className="h-6 w-6"
viewBox="0 0 50 50"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21.2394 36.832L16.5386 39.568C16.5386 39.568 13.7182 36.832 11.8379 33.184C9.95756 29.536 16.5386 23.152 16.5386 23.152M21.2394 36.832H28.7606M21.2394 36.832C21.2394 36.832 15.5985 24.064 17.4788 16.768C19.3591 9.472 25 4 25 4C25 4 30.6409 9.472 32.5212 16.768C34.4015 24.064 28.7606 36.832 28.7606 36.832M28.7606 36.832L33.4614 39.568C33.4614 39.568 36.2818 36.832 38.1621 33.184C40.0424 29.536 33.4614 23.152 33.4614 23.152M25 46L26.8803 40.528H23.1197L25 46ZM25.9402 17.68C26.4594 18.1837 26.4594 19.0003 25.9402 19.504C25.4209 20.0077 24.5791 20.0077 24.0598 19.504C23.5406 19.0003 23.5406 18.1837 24.0598 17.68C24.5791 17.1763 25.4209 17.1763 25.9402 17.68Z" />
</svg>
);
const StreisandIcon = () => (
<svg className="h-6 w-6" viewBox="0 0 50 50" fill="currentColor">
<path d="M25 46L24.2602 47.0076C24.7027 47.3325 25.3054 47.3306 25.7459 47.0031L25 46ZM6.14773 32.1591H4.89773C4.89773 32.557 5.0872 32.9312 5.40797 33.1667L6.14773 32.1591ZM43.6136 32.1591L44.3595 33.1622C44.6767 32.9263 44.8636 32.5543 44.8636 32.1591H43.6136ZM6.14773 19.9886L5.42485 18.9689C5.09421 19.2032 4.89773 19.5834 4.89773 19.9886H6.14773ZM25 6.625L25.729 5.6096L25.0046 5.08952L24.2771 5.60522L25 6.625ZM43.6136 19.9886H44.8636C44.8636 19.586 44.6697 19.208 44.3426 18.9732L43.6136 19.9886ZM25 46L25.7398 44.9924L6.88748 31.1515L6.14773 32.1591L5.40797 33.1667L24.2602 47.0076L25 46ZM43.6136 32.1591L42.8678 31.156L24.2541 44.9969L25 46L25.7459 47.0031L44.3595 33.1622L43.6136 32.1591Z" />
</svg>
);
const getAppIcon = (appName: string): React.ReactNode => {
const name = appName.toLowerCase();
if (name.includes('happ')) return <HappIcon />;
if (name.includes('shadowrocket') || name.includes('rocket')) return <ShadowrocketIcon />;
if (name.includes('streisand')) return <StreisandIcon />;
if (name.includes('clash') || name.includes('meta') || name.includes('verge'))
return <ClashMetaIcon />;
return <span className="text-lg">📦</span>;
};
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'];
// eslint-disable-next-line no-script-url -- listing dangerous schemes to block them
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'];
function isValidExternalUrl(url: string | undefined): boolean {
if (!url) return false;
const lowerUrl = url.toLowerCase().trim();
if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false;
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://');
}
function isValidDeepLink(url: string | undefined): boolean {
if (!url) return false;
const lowerUrl = url.toLowerCase().trim();
if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false;
return lowerUrl.includes('://');
}
function detectPlatform(): string | null {
if (typeof window === 'undefined' || !navigator?.userAgent) return null;
const ua = navigator.userAgent.toLowerCase();
if (/iphone|ipad|ipod/.test(ua)) return 'ios';
if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android';
if (/macintosh|mac os x/.test(ua)) return 'macos';
if (/windows/.test(ua)) return 'windows';
if (/linux/.test(ua)) return 'linux';
return null;
}
function useIsMobile() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === 'undefined') return false;
return window.innerWidth < 768;
});
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, []);
return isMobile;
}
export default function ConnectionModal({ onClose }: ConnectionModalProps) {
const { t, i18n } = useTranslation();
const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null);
const [copied, setCopied] = useState(false);
const [showAppSelector, setShowAppSelector] = useState(false);
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset } =
useTelegramWebApp();
const { impact: hapticImpact } = useHaptic();
const isMobileScreen = useIsMobile();
const isMobile = isMobileScreen;
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Ref для хранения актуального обработчика BackButton (фикс мигания)
const backButtonHandlerRef = useRef<() => void>(() => {});
// Ref for haptic to avoid recreating handleBackButton
const hapticRef = useRef(hapticImpact);
hapticRef.current = hapticImpact;
// Prevent scroll events from bubbling to parent/Telegram
const handleScrollContainerWheel = useCallback((e: React.WheelEvent) => {
const container = e.currentTarget;
const { scrollTop, scrollHeight, clientHeight } = container;
const isAtTop = scrollTop === 0;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1;
// Prevent scroll propagation when not at boundaries, or when scrolling away from boundary
if ((!isAtTop && !isAtBottom) || (isAtTop && e.deltaY > 0) || (isAtBottom && e.deltaY < 0)) {
e.stopPropagation();
}
}, []);
const safeBottom = isTelegramWebApp
? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom)
: 0;
const safeTop = isTelegramWebApp
? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0)
: 0;
const {
data: appConfig,
isLoading,
error,
} = useQuery<AppConfig>({
queryKey: ['appConfig'],
queryFn: () => subscriptionApi.getAppConfig(),
});
const detectedPlatform = useMemo(() => detectPlatform(), []);
useEffect(() => {
if (!appConfig?.platforms || selectedApp) return;
let platform = detectedPlatform;
if (!platform || !appConfig.platforms[platform]?.length) {
platform = platformOrder.find((p) => appConfig.platforms[p]?.length > 0) || null;
}
if (!platform || !appConfig.platforms[platform]?.length) return;
const apps = appConfig.platforms[platform];
const app = apps.find((a) => a.isFeatured) || apps[0];
if (app) setSelectedApp(app);
}, [appConfig, detectedPlatform, selectedApp]);
const handleClose = useCallback(() => {
onClose();
}, [onClose]);
const handleBack = useCallback(() => {
if (selectedPlatform) {
setSelectedPlatform(null);
} else {
setShowAppSelector(false);
}
}, [selectedPlatform]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
if (showAppSelector) handleBack();
else handleClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleClose, handleBack, showAppSelector]);
// Обновляем ref при изменении логики (без перезапуска эффекта BackButton)
useEffect(() => {
backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose;
}, [showAppSelector, handleBack, handleClose]);
// BackButton using platform hook - always close/back, ref provides current handler
const handleBackButton = useCallback(() => {
hapticRef.current('light');
backButtonHandlerRef.current();
}, []);
useBackButton(handleBackButton);
useEffect(() => {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}, []);
const getLocalizedText = (text: LocalizedText | undefined): string => {
if (!text) return '';
const lang = i18n.language || 'en';
return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || '';
};
const availablePlatforms = useMemo(() => {
if (!appConfig?.platforms) return [];
const available = platformOrder.filter((key) => appConfig.platforms[key]?.length > 0);
if (detectedPlatform && available.includes(detectedPlatform)) {
return [detectedPlatform, ...available.filter((p) => p !== detectedPlatform)];
}
return available;
}, [appConfig, detectedPlatform]);
const copySubscriptionLink = async () => {
if (!appConfig?.subscriptionUrl) return;
try {
await navigator.clipboard.writeText(appConfig.subscriptionUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
const textarea = document.createElement('textarea');
textarea.value = appConfig.subscriptionUrl;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleConnect = (app: AppInfo) => {
if (!app.deepLink || !isValidDeepLink(app.deepLink)) return;
const deepLink = app.deepLink;
// All deep links (including custom schemes like happ://, clash://, etc.)
// go through redirect.html. Telegram's openLink opens the redirect page
// in an external browser, which then performs window.location.href to the
// actual deep link URL. This works for both http(s) and custom schemes.
const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(deepLink)}&lang=${i18n.language || 'en'}`;
try {
sdkOpenLink(redirectUrl, { tryInstantView: false });
return;
} catch {
// SDK not available, fallback
}
window.location.href = redirectUrl;
};
// Wrapper component
const Wrapper = ({ children }: { children: React.ReactNode }) => {
if (isMobile) {
const content = (
<div
className="fixed inset-0 z-[9999] flex flex-col bg-dark-900"
style={{
paddingTop: safeTop ? `${safeTop}px` : 'env(safe-area-inset-top, 0px)',
paddingBottom: safeBottom ? `${safeBottom}px` : 'env(safe-area-inset-bottom, 0px)',
}}
>
{children}
</div>
);
if (typeof document !== 'undefined') return createPortal(content, document.body);
return content;
}
// Desktop centered - positioned higher
return (
<div
className="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[8vh]"
onClick={handleClose}
>
<div
className="relative flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
};
// Loading
if (isLoading) {
return (
<Wrapper>
<div className="flex flex-1 items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-[3px] border-accent-500/30 border-t-accent-500" />
</div>
</Wrapper>
);
}
// Error
if (error || !appConfig) {
return (
<Wrapper>
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
<p className="mb-4 text-lg text-dark-300">{t('common.error')}</p>
<button onClick={handleClose} className="btn-primary px-6 py-2">
{t('common.close')}
</button>
</div>
</Wrapper>
);
}
// No subscription
if (!appConfig.hasSubscription) {
return (
<Wrapper>
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
<h3 className="mb-2 text-xl font-bold text-dark-100">
{t('subscription.connection.title')}
</h3>
<p className="mb-4 text-dark-400">{t('subscription.connection.noSubscription')}</p>
<button onClick={handleClose} className="btn-primary px-6 py-2">
{t('common.close')}
</button>
</div>
</Wrapper>
);
}
// App selector
if (showAppSelector) {
const platformNames: Record<string, string> = {
ios: 'iOS',
android: 'Android',
windows: 'Windows',
macos: 'macOS',
linux: 'Linux',
androidTV: 'Android TV',
appleTV: 'Apple TV',
};
const platformIcons: Record<string, React.ReactNode> = {
ios: (
<svg className="h-7 w-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
),
android: (
<svg className="h-7 w-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.6 9.48l1.84-3.18c.16-.31.04-.69-.26-.85-.29-.15-.65-.06-.83.22l-1.88 3.24c-1.39-.59-2.94-.92-4.58-.92s-3.19.33-4.58.92L5.5 5.67c-.16-.28-.54-.37-.83-.22-.31.16-.43.54-.26.85l1.84 3.18C3.38 11.11 1.5 14.12 1.5 17.5h21c0-3.38-1.88-6.39-4.9-8.02zM7 15.25c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25zm10 0c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25z" />
</svg>
),
windows: (
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v6.81l-6-1.15V13zm17 .25V22l-10-1.91V13.1l10 .15z" />
</svg>
),
macos: (
<svg className="h-7 w-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg>
),
linux: (
<svg className="h-6 w-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489.117.779.456 1.462 1.047 1.993.545.487 1.183.805 1.894.983.718.178 1.463.201 2.215.201.752 0 1.498-.023 2.215-.201.711-.178 1.349-.496 1.894-.983.591-.531.93-1.214 1.047-1.993.123-.805-.009-1.657-.287-2.489-.589-1.77-1.831-3.47-2.716-4.521-.75-1.067-.974-1.928-1.05-3.02-.065-1.491 1.056-5.965-3.17-6.298-.165-.013-.325-.021-.48-.021zm-.126 1.09c.99.063 1.783.569 2.172 1.443.376.844.413 1.903.199 2.999-.215 1.096-.687 2.23-1.357 3.18-.669.95-1.405 1.705-2.017 2.379-.611.674-1.074 1.252-1.316 1.857-.242.605-.262 1.233-.006 1.873.256.64.755 1.198 1.36 1.547.606.35 1.247.536 1.88.536.634 0 1.275-.186 1.88-.536.606-.349 1.105-.907 1.36-1.547.256-.64.236-1.268-.006-1.873-.242-.605-.705-1.183-1.316-1.857-.612-.674-1.348-1.429-2.017-2.379-.67-.95-1.142-2.084-1.357-3.18-.214-1.096-.177-2.155.199-2.999.389-.874 1.182-1.38 2.172-1.443z" />
</svg>
),
androidTV: (
<svg className="h-7 w-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" />
</svg>
),
appleTV: (
<svg className="h-7 w-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" />
</svg>
),
};
// Step 1: Platform selection
if (!selectedPlatform) {
return (
<Wrapper>
<div className="flex items-center gap-3 border-b border-dark-800 p-4">
{!isTelegramWebApp && (
<button
onClick={handleBack}
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
>
<BackIcon />
</button>
)}
<h2 className="text-lg font-bold text-dark-100">
{t('subscription.connection.selectPlatform')}
</h2>
</div>
<div className="space-y-2 p-4">
{availablePlatforms.map((platform) => {
const apps = appConfig.platforms[platform];
if (!apps?.length) return null;
const isCurrentPlatform = platform === detectedPlatform;
const appCount = apps.length;
return (
<button
key={platform}
onClick={() => setSelectedPlatform(platform)}
className={`flex w-full items-center gap-4 rounded-xl p-4 transition-all active:scale-[0.98] ${
isCurrentPlatform
? 'bg-accent-500/10 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 hover:bg-dark-800'
}`}
>
<div
className={`flex h-12 w-12 items-center justify-center rounded-xl ${
isCurrentPlatform
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-700 text-dark-300'
}`}
>
{platformIcons[platform] || <span className="text-xl">📱</span>}
</div>
<div className="flex-1 text-left">
<div className="flex items-center gap-2">
<span
className={`font-medium ${isCurrentPlatform ? 'text-accent-400' : 'text-dark-100'}`}
>
{platformNames[platform] || platform}
</span>
{isCurrentPlatform && (
<span className="rounded-full bg-accent-500/10 px-2 py-0.5 text-xs text-accent-500">
{t('subscription.connection.yourDevice')}
</span>
)}
</div>
<span className="text-sm text-dark-400">
{appCount}{' '}
{appCount === 1
? t('subscription.connection.app')
: t('subscription.connection.apps')}
</span>
</div>
<ChevronIcon />
</button>
);
})}
</div>
</Wrapper>
);
}
// Step 2: App selection for chosen platform
const apps = appConfig.platforms[selectedPlatform] || [];
const isCurrentPlatform = selectedPlatform === detectedPlatform;
return (
<Wrapper>
<div className="flex items-center gap-3 border-b border-dark-800 p-4">
{!isTelegramWebApp && (
<button
onClick={handleBack}
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
>
<BackIcon />
</button>
)}
<div className="flex-1">
<h2 className="text-lg font-bold text-dark-100">
{platformNames[selectedPlatform] || selectedPlatform}
</h2>
{isCurrentPlatform && (
<span className="text-xs text-accent-500">
{t('subscription.connection.yourDevice')}
</span>
)}
</div>
</div>
<div
ref={scrollContainerRef}
className={`${isMobile ? 'flex-1' : 'max-h-[60vh]'} overflow-y-auto p-4`}
style={{ overscrollBehavior: 'contain', WebkitOverflowScrolling: 'touch' }}
onWheel={handleScrollContainerWheel}
>
<div className="grid grid-cols-3 gap-3">
{apps.map((app) => {
const isSelected = selectedApp?.id === app.id;
return (
<button
key={app.id}
onClick={() => {
setSelectedApp(app);
setShowAppSelector(false);
setSelectedPlatform(null);
}}
className={`relative flex flex-col items-center rounded-xl p-4 transition-all active:scale-95 ${
isSelected
? 'bg-accent-500/15 ring-2 ring-accent-500/50'
: 'bg-dark-800/50 hover:bg-dark-800'
}`}
>
{/* Featured badge */}
{app.isFeatured && (
<div className="absolute -right-1.5 -top-1.5 flex h-6 w-6 items-center justify-center rounded-full bg-accent-500 shadow-lg">
<svg
className="h-3.5 w-3.5 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
)}
{/* App icon */}
<div
className={`mb-3 flex h-14 w-14 items-center justify-center rounded-2xl transition-colors ${
isSelected
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-700/80 text-dark-300'
}`}
>
{getAppIcon(app.name)}
</div>
{/* App name */}
<span
className={`line-clamp-2 text-center text-sm font-medium leading-tight ${
isSelected ? 'text-accent-400' : 'text-dark-200'
}`}
>
{app.name}
</span>
{/* Selection indicator */}
{isSelected && (
<div className="absolute bottom-2 left-1/2 h-2 w-2 -translate-x-1/2 rounded-full bg-accent-500" />
)}
</button>
);
})}
</div>
</div>
</Wrapper>
);
}
// Main view
return (
<Wrapper>
<div className="border-b border-dark-800 p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-bold text-dark-100">{t('subscription.connection.title')}</h2>
{!isTelegramWebApp && (
<button
onClick={handleClose}
className="-mr-2 rounded-xl p-2 text-dark-400 hover:bg-dark-800"
>
<CloseIcon />
</button>
)}
</div>
<button
onClick={() => setShowAppSelector(true)}
className="flex w-full items-center gap-3 rounded-xl bg-dark-800/50 p-3 transition-colors hover:bg-dark-800"
>
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent-500/10 text-accent-400">
{selectedApp && getAppIcon(selectedApp.name)}
</div>
<div className="flex-1 text-left">
<div className="font-medium text-dark-100">{selectedApp?.name}</div>
<div className="text-sm text-dark-400">{t('subscription.connection.changeApp')}</div>
</div>
<ChevronIcon />
</button>
</div>
<div
className={`${isMobile ? 'flex-1' : 'max-h-[50vh]'} space-y-4 overflow-y-auto p-4`}
style={{ overscrollBehavior: 'contain', WebkitOverflowScrolling: 'touch' }}
onWheel={handleScrollContainerWheel}
>
{selectedApp?.installationStep && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent-500/20 text-xs font-bold text-accent-400">
1
</span>
<span className="font-medium text-dark-100">
{t('subscription.connection.installApp')}
</span>
</div>
<p className="ml-8 text-sm text-dark-400">
{getLocalizedText(selectedApp.installationStep.description)}
</p>
{selectedApp.installationStep.buttons &&
selectedApp.installationStep.buttons.length > 0 && (
<div className="ml-8 flex flex-wrap gap-2">
{selectedApp.installationStep.buttons
.filter((btn) => isValidExternalUrl(btn.buttonLink))
.map((btn, idx) => (
<a
key={idx}
href={btn.buttonLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-dark-800 px-3 py-1.5 text-sm text-dark-200 hover:bg-dark-700"
>
{getLocalizedText(btn.buttonText)}
</a>
))}
</div>
)}
</div>
)}
{selectedApp?.addSubscriptionStep && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent-500/20 text-xs font-bold text-accent-400">
2
</span>
<span className="font-medium text-dark-100">
{t('subscription.connection.addSubscription')}
</span>
</div>
<p className="ml-8 text-sm text-dark-400">
{getLocalizedText(selectedApp.addSubscriptionStep.description)}
</p>
<div className="ml-8 space-y-2">
{selectedApp.deepLink && (
<button
onClick={() => handleConnect(selectedApp)}
className="btn-primary flex h-11 w-full items-center justify-center gap-2 text-sm font-semibold"
>
<LinkIcon />
{t('subscription.connection.addToApp', { appName: selectedApp.name })}
</button>
)}
{/* Copy link button - hidden when hideLink is true */}
{!appConfig?.hideLink && (
<button
onClick={copySubscriptionLink}
className={`flex h-11 w-full items-center justify-center gap-2 rounded-xl border text-sm font-medium transition-all ${
copied
? 'border-success-500 bg-success-500/10 text-success-400'
: 'border-dark-600 text-dark-300 hover:bg-dark-800'
}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied
? t('subscription.connection.copied')
: t('subscription.connection.copyLink')}
</button>
)}
</div>
</div>
)}
{selectedApp?.connectAndUseStep && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-success-500/20 text-xs font-bold text-success-400">
3
</span>
<span className="font-medium text-dark-100">
{t('subscription.connection.connectVpn')}
</span>
</div>
<p className="ml-8 text-sm text-dark-400">
{getLocalizedText(selectedApp.connectAndUseStep.description)}
</p>
</div>
)}
</div>
</Wrapper>
);
}

View File

@@ -0,0 +1,93 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface ErrorBoundaryProps {
children: ReactNode;
level?: 'app' | 'page' | 'widget';
onReset?: () => void;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('[ErrorBoundary]', error, errorInfo);
}
handleReset = () => {
this.setState({ hasError: false, error: null });
this.props.onReset?.();
};
render() {
if (!this.state.hasError) {
return this.props.children;
}
const { level = 'page' } = this.props;
if (level === 'app') {
return (
<div className="flex min-h-screen items-center justify-center bg-dark-900 p-4">
<div className="max-w-md text-center">
<div className="mb-4 text-4xl"></div>
<h1 className="mb-2 text-xl font-bold text-dark-50">Something went wrong</h1>
<p className="mb-6 text-dark-400">
An unexpected error occurred. Please try reloading the page.
</p>
<button
onClick={() => window.location.reload()}
className="rounded-xl bg-accent-500 px-6 py-3 font-medium text-white transition-colors hover:bg-accent-600"
>
Reload page
</button>
</div>
</div>
);
}
if (level === 'widget') {
return (
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-center">
<p className="text-sm text-error-400">Failed to load this section</p>
<button
onClick={this.handleReset}
className="mt-2 text-sm text-accent-400 hover:text-accent-300"
>
Try again
</button>
</div>
);
}
// level === 'page' (default)
return (
<div className="flex min-h-[50vh] items-center justify-center p-4">
<div className="max-w-md text-center">
<div className="mb-4 text-4xl"></div>
<h1 className="mb-2 text-xl font-bold text-dark-50">Something went wrong</h1>
<p className="mb-6 text-sm text-dark-400">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={this.handleReset}
className="rounded-xl bg-accent-500 px-6 py-3 font-medium text-white transition-colors hover:bg-accent-600"
>
Try again
</button>
</div>
</div>
);
}
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useCurrency } from '../hooks/useCurrency';

View File

@@ -1,385 +0,0 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { promoApi } from '../api/promo';
const SparklesIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
);
const ClockIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const XCircleIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const WarningIcon = () => (
<svg
className="h-12 w-12"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
);
const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => {
const now = new Date();
// Ensure UTC parsing - if no timezone specified, assume UTC
let expires: Date;
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
expires = new Date(expiresAt);
} else {
// No timezone - treat as UTC
expires = new Date(expiresAt + 'Z');
}
const diffMs = expires.getTime() - now.getTime();
if (diffMs <= 0) return '';
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days}${t('promo.time.days')}`;
}
if (hours > 0) {
return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`;
}
return `${minutes}${t('promo.time.minutes')}`;
};
// ------- Confirmation Modal -------
interface DeactivateConfirmModalProps {
isOpen: boolean;
discountPercent: number;
isDeactivating: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function DeactivateConfirmModal({
isOpen,
discountPercent,
isDeactivating,
onConfirm,
onCancel,
}: DeactivateConfirmModalProps) {
const { t } = useTranslation();
// Escape key to close
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isDeactivating) {
e.preventDefault();
onCancel();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, isDeactivating, onCancel]);
// Scroll lock
useEffect(() => {
if (!isOpen) return;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
if (!isOpen) return null;
const modalContent = (
<div
className="fixed inset-0 z-[100] flex items-center justify-center"
role="dialog"
aria-modal="true"
aria-labelledby="deactivate-modal-title"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={isDeactivating ? undefined : onCancel}
/>
{/* Modal */}
<div
className="relative mx-4 w-full max-w-sm overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* Warning header */}
<div className="flex flex-col items-center bg-gradient-to-br from-warning-500/20 to-red-500/20 px-6 pb-6 pt-8">
<div className="mb-3 text-warning-400">
<WarningIcon />
</div>
<h2 id="deactivate-modal-title" className="text-center text-lg font-bold text-dark-100">
{t('promo.deactivate.confirmTitle')}
</h2>
<p className="mt-2 text-center text-sm text-dark-300">
{t('promo.deactivate.confirmDescription', { percent: discountPercent })}
</p>
</div>
{/* Warning text */}
<div className="px-6 py-4">
<div className="rounded-lg border border-warning-500/20 bg-warning-500/10 px-4 py-3">
<p className="text-center text-sm text-warning-400">{t('promo.deactivate.warning')}</p>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-3 px-6 pb-6">
<button
onClick={onCancel}
disabled={isDeactivating}
className="flex-1 rounded-xl bg-dark-800 py-3 font-semibold text-dark-300 transition-colors hover:bg-dark-700 hover:text-dark-100 disabled:opacity-50"
>
{t('common.cancel')}
</button>
<button
onClick={onConfirm}
disabled={isDeactivating}
className="flex-1 rounded-xl bg-gradient-to-r from-red-500 to-red-600 py-3 font-semibold text-white shadow-lg shadow-red-500/25 transition-all hover:from-red-400 hover:to-red-500 active:from-red-600 active:to-red-700 disabled:opacity-50"
>
{isDeactivating ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
{t('promo.deactivate.deactivating')}
</span>
) : (
t('promo.deactivate.confirm')
)}
</button>
</div>
</div>
</div>
);
if (typeof document !== 'undefined') {
return createPortal(modalContent, document.body);
}
return modalContent;
}
// ------- Main Component -------
export default function PromoDiscountBadge() {
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [isOpen, setIsOpen] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [deactivateError, setDeactivateError] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const { data: activeDiscount } = useQuery({
queryKey: ['active-discount'],
queryFn: promoApi.getActiveDiscount,
staleTime: 30000,
refetchInterval: 60000, // Refresh every minute
});
const deactivateMutation = useMutation({
mutationFn: async () => {
const source = activeDiscount?.source;
if (source && source.startsWith('promocode:')) {
await promoApi.deactivateDiscount();
} else {
await promoApi.clearActiveDiscount();
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
setShowConfirmModal(false);
setIsOpen(false);
setDeactivateError(null);
},
onError: (error: unknown) => {
const axiosErr = error as { response?: { data?: { detail?: string } } };
setDeactivateError(axiosErr.response?.data?.detail || t('promo.deactivate.error'));
},
});
const handleCloseConfirmModal = useCallback(() => {
if (!deactivateMutation.isPending) {
setShowConfirmModal(false);
setDeactivateError(null);
}
}, [deactivateMutation.isPending]);
const handleConfirmDeactivate = useCallback(() => {
setDeactivateError(null);
deactivateMutation.mutate();
}, [deactivateMutation]);
// Close dropdown on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Don't render if no active discount
if (!activeDiscount || !activeDiscount.is_active || !activeDiscount.discount_percent) {
return null;
}
const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null;
const handleClick = () => {
setIsOpen(!isOpen);
};
const handleGoToSubscription = () => {
setIsOpen(false);
navigate('/subscription');
};
const handleDeactivateClick = () => {
setDeactivateError(null);
setShowConfirmModal(true);
};
return (
<>
<div className="relative" ref={dropdownRef}>
{/* Badge button */}
<button
onClick={handleClick}
className="group relative flex items-center gap-1 rounded-lg bg-success-500/15 px-2.5 py-1.5 transition-all hover:bg-success-500/25"
title={t('promo.activeDiscount', 'Active discount')}
>
<span className="text-sm font-bold text-success-400">
-{activeDiscount.discount_percent}%
</span>
</button>
{/* Dropdown - mobile: fixed centered, desktop: absolute right */}
{isOpen && (
<>
{/* Mobile backdrop */}
<div
className="fixed inset-0 z-40 bg-black/30 sm:hidden"
onClick={() => setIsOpen(false)}
/>
<div className="fixed left-4 right-4 top-20 z-50 animate-fade-in overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800 shadow-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-72">
{/* Header */}
<div className="border-b border-dark-700/50 bg-gradient-to-r from-success-500/20 to-accent-500/20 p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-success-500/20 text-success-400">
<SparklesIcon />
</div>
<div>
<div className="font-semibold text-dark-100">{t('promo.discountActive')}</div>
<div className="text-2xl font-bold text-success-400">
-{activeDiscount.discount_percent}%
</div>
</div>
</div>
</div>
{/* Content */}
<div className="space-y-3 p-4">
<p className="text-sm text-dark-300">{t('promo.discountDescription')}</p>
{/* Time remaining */}
{timeLeft && (
<div className="flex items-center gap-2 rounded-lg bg-dark-900/50 px-3 py-2 text-sm text-dark-400">
<ClockIcon />
<span>
{t('promo.expiresIn')}:{' '}
<span className="font-medium text-warning-400">{timeLeft}</span>
</span>
</div>
)}
{/* Deactivation error */}
{deactivateError && (
<div className="rounded-lg border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-400">
{deactivateError}
</div>
)}
{/* CTA Button */}
<button
onClick={handleGoToSubscription}
className="btn-primary w-full py-2.5 text-sm font-medium"
>
{t('promo.useNow')}
</button>
{/* Deactivate Button */}
<button
onClick={handleDeactivateClick}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dark-600/50 bg-dark-900/50 py-2 text-sm text-dark-400 transition-colors hover:border-red-500/30 hover:bg-red-500/10 hover:text-red-400"
aria-label={t('promo.deactivate.button')}
>
<XCircleIcon />
<span>{t('promo.deactivate.button')}</span>
</button>
</div>
</div>
</>
)}
</div>
{/* Deactivation Confirmation Modal */}
<DeactivateConfirmModal
isOpen={showConfirmModal}
discountPercent={activeDiscount.discount_percent}
isDeactivating={deactivateMutation.isPending}
onConfirm={handleConfirmDeactivate}
onCancel={handleCloseConfirmModal}
/>
</>
);
}

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { promoApi, PromoOffer } from '../api/promo';
import { ClockIcon, CheckIcon } from './icons';
import { usePlatform } from '@/platform/hooks/usePlatform';

View File

@@ -6,11 +6,11 @@
import { useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useSuccessNotification } from '../store/successNotification';
import { useCurrency } from '../hooks/useCurrency';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { useBackButton, useHaptic } from '@/platform';
import { useTelegramSDK } from '../hooks/useTelegramSDK';
import { useHaptic } from '@/platform';
// Icons
const CheckCircleIcon = () => (
@@ -80,7 +80,7 @@ export default function SuccessNotificationModal() {
const navigate = useNavigate();
const { isOpen, data, hide } = useSuccessNotification();
const { formatAmount, currencySymbol } = useCurrency();
const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramWebApp();
const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramSDK();
const haptic = useHaptic();
const safeBottom = isTelegramWebApp
@@ -106,9 +106,6 @@ export default function SuccessNotificationModal() {
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, handleClose]);
// Telegram back button - using platform hook
useBackButton(isOpen ? handleClose : null);
// Haptic feedback on open
useEffect(() => {
if (isOpen) {

View File

@@ -1,579 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets';
import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion';
import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme';
import { applyThemeColors } from '../hooks/useThemeColors';
interface ThemeBentoPickerProps {
currentColors: ThemeColors;
onColorsChange: (colors: ThemeColors) => void;
onSave: () => void;
isSaving: boolean;
}
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
const ChevronDownIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
const MoonIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
);
const SunIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
);
const StatusIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg>
);
const PaletteIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
/>
</svg>
);
function PresetCard({
preset,
isSelected,
onClick,
}: {
preset: ColorPreset;
isSelected: boolean;
onClick: () => void;
}) {
const { i18n } = useTranslation();
const isRu = i18n.language === 'ru';
return (
<button
onClick={onClick}
className={`group relative flex h-full w-full flex-col rounded-2xl p-3 text-left transition-all duration-200 ${
isSelected
? 'z-10 scale-[1.02] border-2 border-accent-500 bg-dark-800/90 shadow-lg shadow-accent-500/20'
: 'border border-dark-700/50 bg-dark-900/60 hover:scale-[1.01] hover:border-dark-600/60 hover:bg-dark-800/70'
}`}
>
<div
className="relative mb-2.5 h-12 w-full shrink-0 overflow-hidden rounded-xl"
style={{ backgroundColor: preset.preview.background }}
>
<div
className="absolute bottom-1.5 left-1.5 h-6 w-6 rounded-lg shadow-md"
style={{ backgroundColor: preset.preview.accent }}
/>
<div
className="absolute bottom-2.5 right-2 h-1 w-10 rounded-full opacity-60"
style={{ backgroundColor: preset.preview.text }}
/>
<div
className="absolute bottom-5 right-2 h-1 w-7 rounded-full opacity-40"
style={{ backgroundColor: preset.preview.text }}
/>
</div>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="truncate text-xs font-semibold text-dark-100">
{isRu ? preset.nameRu : preset.name}
</h4>
</div>
{isSelected && (
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent-500 text-white">
<CheckIcon />
</div>
)}
</div>
</button>
);
}
function HSLSlider({
label,
value,
onChange,
max,
gradient,
suffix = '',
}: {
label: string;
value: number;
onChange: (value: number) => void;
max: number;
gradient: string;
suffix?: string;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-medium text-dark-300">{label}</label>
<span className="font-mono text-xs tabular-nums text-dark-500">
{value}
{suffix}
</span>
</div>
<input
type="range"
min="0"
max={max}
value={value}
onChange={(e) => onChange(parseInt(e.target.value))}
className="h-2.5 w-full cursor-pointer appearance-none rounded-full"
style={{ background: gradient }}
/>
</div>
);
}
function CompactColorInput({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (color: string) => void;
}) {
const [localValue, setLocalValue] = useState(value);
const [isEditing, setIsEditing] = useState(false);
useEffect(() => {
setLocalValue(value);
}, [value]);
const handleChange = (newValue: string) => {
let formatted = newValue.toUpperCase();
if (!formatted.startsWith('#')) {
formatted = '#' + formatted;
}
setLocalValue(formatted);
if (isValidHex(formatted)) {
onChange(formatted);
}
};
const handleBlur = () => {
setIsEditing(false);
if (!isValidHex(localValue)) {
setLocalValue(value);
}
};
return (
<div className="group flex items-center gap-2 rounded-xl bg-dark-800/40 p-2 transition-colors hover:bg-dark-800/60">
<button
type="button"
onClick={() => setIsEditing(true)}
className="h-8 w-8 shrink-0 rounded-lg border border-dark-600/50 shadow-inner transition-transform hover:scale-105"
style={{ backgroundColor: value }}
/>
<div className="min-w-0 flex-1">
<label className="mb-0.5 block text-[10px] uppercase leading-none tracking-wide text-dark-500">
{label}
</label>
{isEditing ? (
<input
type="text"
value={localValue}
onChange={(e) => handleChange(e.target.value)}
onBlur={handleBlur}
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
autoFocus
className="w-full bg-transparent font-mono text-xs text-dark-200 outline-none"
maxLength={7}
/>
) : (
<button
type="button"
onClick={() => setIsEditing(true)}
className="text-left font-mono text-xs text-dark-300 transition-colors hover:text-dark-100"
>
{value.toUpperCase()}
</button>
)}
</div>
</div>
);
}
function CollapsibleSection({
title,
icon,
isOpen,
onToggle,
children,
badge,
}: {
title: string;
icon: React.ReactNode;
isOpen: boolean;
onToggle: () => void;
children: React.ReactNode;
badge?: string;
}) {
return (
<div className="overflow-hidden rounded-2xl border border-dark-700/40 bg-dark-900/50">
<button
onClick={onToggle}
className="flex w-full items-center justify-between px-4 py-3 transition-colors hover:bg-dark-800/30"
>
<div className="flex items-center gap-2.5">
<div className="text-dark-400">{icon}</div>
<span className="text-sm font-medium text-dark-200">{title}</span>
{badge && (
<span className="rounded-md bg-dark-700/50 px-1.5 py-0.5 font-mono text-[10px] text-dark-400">
{badge}
</span>
)}
</div>
<div
className={`text-dark-500 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
>
<ChevronDownIcon />
</div>
</button>
<div
className={`grid transition-all duration-200 ease-out ${
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
}`}
>
<div className="overflow-hidden">
<div className="border-t border-dark-700/30 px-4 pb-4 pt-1">{children}</div>
</div>
</div>
</div>
);
}
export function ThemeBentoPicker({
currentColors,
onColorsChange,
onSave,
isSaving,
}: ThemeBentoPickerProps) {
const { t } = useTranslation();
const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent));
const [hexInput, setHexInput] = useState(currentColors.accent);
const [hasChanges, setHasChanges] = useState(false);
const [isPresetsOpen, setIsPresetsOpen] = useState(false);
const [isAccentOpen, setIsAccentOpen] = useState(false);
const [isDarkOpen, setIsDarkOpen] = useState(false);
const [isLightOpen, setIsLightOpen] = useState(false);
const [isStatusOpen, setIsStatusOpen] = useState(false);
const selectedPresetId = useMemo(() => {
const match = COLOR_PRESETS.find(
(p) =>
p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() &&
p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() &&
p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase(),
);
return match?.id ?? null;
}, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]);
useEffect(() => {
setHsl(hexToHsl(currentColors.accent));
setHexInput(currentColors.accent);
}, [currentColors.accent]);
const updateColor = useCallback(
(key: keyof ThemeColors, value: string) => {
const newColors = { ...currentColors, [key]: value };
onColorsChange(newColors);
applyThemeColors(newColors);
setHasChanges(true);
},
[currentColors, onColorsChange],
);
const updateAccentFromHsl = useCallback(
(newHsl: HSLColor) => {
setHsl(newHsl);
const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l);
setHexInput(newHex);
updateColor('accent', newHex);
},
[updateColor],
);
const handleHexInputChange = (value: string) => {
setHexInput(value);
if (isValidHex(value)) {
const newHsl = hexToHsl(value);
setHsl(newHsl);
updateColor('accent', value);
}
};
const handlePresetSelect = (preset: ColorPreset) => {
onColorsChange(preset.colors);
applyThemeColors(preset.colors);
setHasChanges(true);
};
const hueGradient = useMemo(() => {
return `linear-gradient(to right,
hsl(0, ${hsl.s}%, ${hsl.l}%),
hsl(60, ${hsl.s}%, ${hsl.l}%),
hsl(120, ${hsl.s}%, ${hsl.l}%),
hsl(180, ${hsl.s}%, ${hsl.l}%),
hsl(240, ${hsl.s}%, ${hsl.l}%),
hsl(300, ${hsl.s}%, ${hsl.l}%),
hsl(360, ${hsl.s}%, ${hsl.l}%)
)`;
}, [hsl.s, hsl.l]);
const saturationGradient = useMemo(() => {
return `linear-gradient(to right,
hsl(${hsl.h}, 0%, ${hsl.l}%),
hsl(${hsl.h}, 100%, ${hsl.l}%)
)`;
}, [hsl.h, hsl.l]);
const lightnessGradient = useMemo(() => {
return `linear-gradient(to right,
hsl(${hsl.h}, ${hsl.s}%, 0%),
hsl(${hsl.h}, ${hsl.s}%, 50%),
hsl(${hsl.h}, ${hsl.s}%, 100%)
)`;
}, [hsl.h, hsl.s]);
return (
<div className="space-y-5">
<CollapsibleSection
title={t('admin.theme.quickPresets', 'Quick Presets')}
icon={<PaletteIcon />}
badge={`${COLOR_PRESETS.length}`}
isOpen={isPresetsOpen}
onToggle={() => setIsPresetsOpen(!isPresetsOpen)}
>
<div className="grid auto-rows-fr grid-cols-2 gap-4 p-1 sm:grid-cols-3">
{COLOR_PRESETS.map((preset, index) => (
<div
key={preset.id}
className="min-h-[100px]"
style={{ '--stagger': index } as React.CSSProperties}
>
<PresetCard
preset={preset}
isSelected={selectedPresetId === preset.id}
onClick={() => handlePresetSelect(preset)}
/>
</div>
))}
</div>
</CollapsibleSection>
<div className="space-y-2">
<h3 className="text-xs font-medium uppercase tracking-wide text-dark-400">
{t('admin.theme.customizeColors', 'Customize Colors')}
</h3>
<CollapsibleSection
title={t('admin.theme.accentColor', 'Accent Color')}
icon={<PaletteIcon />}
badge={hexInput.toUpperCase()}
isOpen={isAccentOpen}
onToggle={() => setIsAccentOpen(!isAccentOpen)}
>
<div className="space-y-4">
<div
className="relative h-14 w-full overflow-hidden rounded-xl shadow-inner"
style={{
background: `linear-gradient(135deg, ${hexInput} 0%, ${hslToHex(hsl.h, hsl.s, Math.max(20, hsl.l - 20))} 100%)`,
}}
>
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" />
<div className="absolute bottom-2 right-3 font-mono text-xs text-white/80 drop-shadow">
{hexInput.toUpperCase()}
</div>
</div>
<HSLSlider
label={t('admin.theme.hue', 'Hue')}
value={hsl.h}
onChange={(h) => updateAccentFromHsl({ ...hsl, h })}
max={360}
gradient={hueGradient}
suffix="°"
/>
<HSLSlider
label={t('admin.theme.saturation', 'Saturation')}
value={hsl.s}
onChange={(s) => updateAccentFromHsl({ ...hsl, s })}
max={100}
gradient={saturationGradient}
suffix="%"
/>
<HSLSlider
label={t('admin.theme.lightness', 'Lightness')}
value={hsl.l}
onChange={(l) => updateAccentFromHsl({ ...hsl, l })}
max={100}
gradient={lightnessGradient}
suffix="%"
/>
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.theme.hexCode', 'HEX Code')}
</label>
<input
type="text"
value={hexInput}
onChange={(e) => handleHexInputChange(e.target.value)}
placeholder="#3b82f6"
maxLength={7}
className="input w-full font-mono text-sm uppercase"
/>
</div>
</div>
</CollapsibleSection>
<CollapsibleSection
title={t('theme.darkTheme', 'Dark Theme')}
icon={<MoonIcon />}
isOpen={isDarkOpen}
onToggle={() => setIsDarkOpen(!isDarkOpen)}
>
<div className="grid grid-cols-2 gap-2">
<CompactColorInput
label={t('theme.background', 'Background')}
value={currentColors.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
onChange={(c) => updateColor('darkBackground', c)}
/>
<CompactColorInput
label={t('theme.surface', 'Surface')}
value={currentColors.darkSurface || DEFAULT_THEME_COLORS.darkSurface}
onChange={(c) => updateColor('darkSurface', c)}
/>
<CompactColorInput
label={t('theme.text', 'Text')}
value={currentColors.darkText || DEFAULT_THEME_COLORS.darkText}
onChange={(c) => updateColor('darkText', c)}
/>
<CompactColorInput
label={t('theme.textSecondary', 'Secondary')}
value={currentColors.darkTextSecondary || DEFAULT_THEME_COLORS.darkTextSecondary}
onChange={(c) => updateColor('darkTextSecondary', c)}
/>
</div>
</CollapsibleSection>
<CollapsibleSection
title={t('theme.lightTheme', 'Light Theme')}
icon={<SunIcon />}
isOpen={isLightOpen}
onToggle={() => setIsLightOpen(!isLightOpen)}
>
<div className="grid grid-cols-2 gap-2">
<CompactColorInput
label={t('theme.background', 'Background')}
value={currentColors.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
onChange={(c) => updateColor('lightBackground', c)}
/>
<CompactColorInput
label={t('theme.surface', 'Surface')}
value={currentColors.lightSurface || DEFAULT_THEME_COLORS.lightSurface}
onChange={(c) => updateColor('lightSurface', c)}
/>
<CompactColorInput
label={t('theme.text', 'Text')}
value={currentColors.lightText || DEFAULT_THEME_COLORS.lightText}
onChange={(c) => updateColor('lightText', c)}
/>
<CompactColorInput
label={t('theme.textSecondary', 'Secondary')}
value={currentColors.lightTextSecondary || DEFAULT_THEME_COLORS.lightTextSecondary}
onChange={(c) => updateColor('lightTextSecondary', c)}
/>
</div>
</CollapsibleSection>
<CollapsibleSection
title={t('theme.statusColors', 'Status Colors')}
icon={<StatusIcon />}
isOpen={isStatusOpen}
onToggle={() => setIsStatusOpen(!isStatusOpen)}
>
<div className="grid grid-cols-3 gap-2">
<CompactColorInput
label={t('theme.success', 'Success')}
value={currentColors.success || DEFAULT_THEME_COLORS.success}
onChange={(c) => updateColor('success', c)}
/>
<CompactColorInput
label={t('theme.warning', 'Warning')}
value={currentColors.warning || DEFAULT_THEME_COLORS.warning}
onChange={(c) => updateColor('warning', c)}
/>
<CompactColorInput
label={t('theme.error', 'Error')}
value={currentColors.error || DEFAULT_THEME_COLORS.error}
onChange={(c) => updateColor('error', c)}
/>
</div>
</CollapsibleSection>
</div>
<div className="rounded-2xl border border-dark-700/40 bg-dark-900/50 p-4">
<h4 className="mb-3 text-xs font-medium uppercase tracking-wide text-dark-400">
{t('theme.preview', 'Preview')}
</h4>
<div className="flex flex-wrap gap-2">
<button className="btn-primary text-sm">{t('theme.previewButton', 'Primary')}</button>
<button className="btn-secondary text-sm">
{t('theme.previewSecondary', 'Secondary')}
</button>
<span className="badge-success">{t('theme.success', 'Success')}</span>
<span className="badge-warning">{t('theme.warning', 'Warning')}</span>
<span className="badge-error">{t('theme.error', 'Error')}</span>
</div>
</div>
{hasChanges && (
<div className="flex animate-fade-in justify-end">
<button onClick={onSave} disabled={isSaving} className="btn-primary">
{isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
</button>
</div>
)}
</div>
);
}

View File

@@ -1,12 +1,12 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { ticketNotificationsApi } from '../api/ticketNotifications';
import { useAuthStore } from '../store/auth';
import { useToast } from './Toast';
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { useTelegramSDK } from '../hooks/useTelegramSDK';
import type { TicketNotification } from '../types';
const BellIcon = () => (
@@ -37,7 +37,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
const { showToast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramSDK();
// Calculate dropdown top position (account for fullscreen safe area + TG buttons)
const dropdownTop = isFullscreen

View File

@@ -27,6 +27,8 @@ interface ToastContextType {
const ToastContext = createContext<ToastContextType | null>(null);
const MAX_VISIBLE = 3;
// eslint-disable-next-line react-refresh/only-export-components
export function useToast() {
const context = useContext(ToastContext);
@@ -40,22 +42,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const showToast = useCallback((options: ToastOptions) => {
const id = Date.now() + Math.random(); // Avoid ID collision
const toast: Toast = { id, duration: 5000, type: 'info', ...options };
setToasts((prev) => [...prev, toast]);
const timer = setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
timersRef.current.delete(id);
}, toast.duration);
timersRef.current.set(id, timer);
}, []);
const removeToast = useCallback((id: number) => {
// Clear timer when manually removing
const timer = timersRef.current.get(id);
if (timer) {
clearTimeout(timer);
@@ -64,6 +51,37 @@ export function ToastProvider({ children }: { children: ReactNode }) {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const showToast = useCallback(
(options: ToastOptions) => {
const id = Date.now() + Math.random();
const toast: Toast = { id, duration: 5000, type: 'info', ...options };
setToasts((prev) => {
const next = [...prev, toast];
// Evict oldest toasts beyond the limit
if (next.length > MAX_VISIBLE) {
const evicted = next.slice(0, next.length - MAX_VISIBLE);
for (const old of evicted) {
const timer = timersRef.current.get(old.id);
if (timer) {
clearTimeout(timer);
timersRef.current.delete(old.id);
}
}
return next.slice(-MAX_VISIBLE);
}
return next;
});
const timer = setTimeout(() => {
removeToast(id);
}, toast.duration);
timersRef.current.set(id, timer);
},
[removeToast],
);
// Cleanup all timers on unmount
useEffect(() => {
const timers = timersRef.current;
@@ -77,8 +95,8 @@ export function ToastProvider({ children }: { children: ReactNode }) {
<ToastContext.Provider value={{ showToast }}>
{children}
{/* Toast Container */}
<div className="pointer-events-none fixed right-4 top-4 z-[100] flex flex-col gap-3">
{/* Toast Container — safe area aware, adaptive width */}
<div className="pointer-events-none fixed left-4 right-4 top-[calc(1rem+env(safe-area-inset-top,0px))] z-[100] flex flex-col gap-3 sm:left-auto sm:right-[calc(1rem+env(safe-area-inset-right,0px))]">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
))}
@@ -89,40 +107,34 @@ export function ToastProvider({ children }: { children: ReactNode }) {
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
const handleClick = () => {
if (toast.onClick) {
toast.onClick();
toast.onClick?.();
onClose();
}
};
const typeStyles = {
success: {
bg: 'bg-dark-800',
accent: 'bg-gradient-to-r from-success-500/50 to-transparent',
border: 'border-success-500/70',
border: 'border-l-success-500',
icon: 'text-success-400',
iconBg: 'bg-success-500/30',
iconBg: 'bg-success-500/20',
progress: 'bg-success-400',
},
error: {
bg: 'bg-dark-800',
accent: 'bg-gradient-to-r from-error-500/50 to-transparent',
border: 'border-error-500/70',
border: 'border-l-error-500',
icon: 'text-error-400',
iconBg: 'bg-error-500/30',
iconBg: 'bg-error-500/20',
progress: 'bg-error-400',
},
warning: {
bg: 'bg-dark-800',
accent: 'bg-gradient-to-r from-warning-500/50 to-transparent',
border: 'border-warning-500/70',
border: 'border-l-warning-500',
icon: 'text-warning-400',
iconBg: 'bg-warning-500/30',
iconBg: 'bg-warning-500/20',
progress: 'bg-warning-400',
},
info: {
bg: 'bg-dark-800',
accent: 'bg-gradient-to-r from-accent-500/50 to-transparent',
border: 'border-accent-500/70',
border: 'border-l-accent-500',
icon: 'text-accent-400',
iconBg: 'bg-accent-500/30',
iconBg: 'bg-accent-500/20',
progress: 'bg-accent-400',
},
};
@@ -185,17 +197,14 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
return (
<div
className={`pointer-events-auto w-80 sm:w-96 ${style.bg} border ${style.border} animate-slide-in-right overflow-hidden rounded-2xl shadow-2xl shadow-black/40 ${toast.onClick ? 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]' : ''} transition-transform duration-200`}
className={`pointer-events-auto w-full cursor-pointer border border-l-4 border-dark-700 ${style.border} animate-slide-in-right overflow-hidden rounded-2xl bg-dark-900 shadow-2xl shadow-black/50 backdrop-blur-xl transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98] sm:max-w-sm`}
onClick={handleClick}
>
{/* Color accent */}
<div className={`absolute inset-0 ${style.accent}`} />
<div className="relative p-4">
<div className="flex gap-3">
{/* Icon */}
<div
className={`h-10 w-10 flex-shrink-0 rounded-xl ${style.iconBg} flex items-center justify-center ${style.icon}`}
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl ${style.iconBg} ${style.icon}`}
>
{toast.icon || defaultIcons[toast.type || 'info']}
</div>
@@ -207,31 +216,12 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
)}
<p className="text-sm leading-relaxed text-dark-300">{toast.message}</p>
</div>
{/* Close button */}
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg text-dark-500 transition-colors hover:bg-dark-700/50 hover:text-dark-300"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Progress bar */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-dark-800/50">
<div
className={`h-full ${style.icon.replace('text-', 'bg-')} opacity-60`}
className={`h-full ${style.progress} opacity-60`}
style={{
animation: `shrink ${toast.duration}ms linear forwards`,
}}

View File

@@ -4,7 +4,7 @@
*/
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query';
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';

View File

@@ -1,4 +1,4 @@
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { usePlatform } from '@/platform';
import { BackIcon } from './icons';

View File

@@ -1,14 +0,0 @@
import type { ReactNode } from 'react';
interface AdminLayoutProps {
children: ReactNode;
className?: string;
}
/**
* AdminLayout - wrapper for all admin pages.
* Animations removed to prevent black flash during transitions.
*/
export function AdminLayout({ children, className }: AdminLayoutProps) {
return <div className={className}>{children}</div>;
}

View File

@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { brandingApi, setCachedBranding } from '../../api/branding';
import { setCachedAnimationEnabled } from '../AnimatedBackground';
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp';
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramSDK';
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons';
import { Toggle } from './Toggle';

View File

@@ -1,75 +0,0 @@
import { useTranslation } from 'react-i18next';
import { AdminBackButton, StarIcon, CloseIcon, MENU_SECTIONS } from './index';
interface SettingsSidebarProps {
activeSection: string;
setActiveSection: (section: string) => void;
mobileMenuOpen: boolean;
setMobileMenuOpen: (open: boolean) => void;
favoritesCount: number;
}
export function SettingsSidebar({
activeSection,
setActiveSection,
mobileMenuOpen,
setMobileMenuOpen,
favoritesCount,
}: SettingsSidebarProps) {
const { t } = useTranslation();
return (
<aside
className={`fixed inset-y-0 left-0 z-50 h-screen w-64 flex-shrink-0 transform border-r border-dark-700/50 bg-dark-900 transition-transform duration-200 ease-in-out lg:sticky lg:top-0 ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} `}
>
{/* Header */}
<div className="border-b border-dark-700/50 p-4">
<div className="flex items-center gap-3">
<AdminBackButton className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700" />
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
<button
onClick={() => setMobileMenuOpen(false)}
className="ml-auto rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700 lg:hidden"
>
<CloseIcon />
</button>
</div>
</div>
{/* Menu */}
<nav className="max-h-[calc(100vh-80px)] space-y-1 overflow-y-auto p-2">
{MENU_SECTIONS.map((section, sectionIdx) => (
<div key={section.id}>
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
{section.items.map((item) => {
const isActive = activeSection === item.id;
const hasIcon = item.iconType === 'star';
return (
<button
key={item.id}
onClick={() => {
setActiveSection(item.id);
setMobileMenuOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 transition-all ${
isActive
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200'
}`}
>
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
{item.id === 'favorites' && favoritesCount > 0 && (
<span className="ml-auto rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
{favoritesCount}
</span>
)}
</button>
);
})}
</div>
))}
</nav>
</aside>
);
}

View File

@@ -1,8 +1,8 @@
import { useState } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { themeColorsApi } from '../../api/themeColors';
import { DEFAULT_THEME_COLORS } from '../../types/theme';
import { DEFAULT_THEME_COLORS, ThemeColors } from '../../types/theme';
import { ColorPicker } from '../ColorPicker';
import { applyThemeColors } from '../../hooks/useThemeColors';
import { updateEnabledThemesCache } from '../../hooks/useTheme';
@@ -10,6 +10,23 @@ import { MoonIcon, SunIcon, ChevronDownIcon } from './icons';
import { Toggle } from './Toggle';
import { THEME_PRESETS } from './constants';
function colorsEqual(a: ThemeColors, b: ThemeColors): boolean {
return (
a.accent === b.accent &&
a.darkBackground === b.darkBackground &&
a.darkSurface === b.darkSurface &&
a.darkText === b.darkText &&
a.darkTextSecondary === b.darkTextSecondary &&
a.lightBackground === b.lightBackground &&
a.lightSurface === b.lightSurface &&
a.lightText === b.lightText &&
a.lightTextSecondary === b.lightTextSecondary &&
a.success === b.success &&
a.warning === b.warning &&
a.error === b.error
);
}
export function ThemeTab() {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -29,7 +46,7 @@ export function ThemeTab() {
};
// Queries
const { data: themeColors } = useQuery({
const { data: serverColors } = useQuery({
queryKey: ['theme-colors'],
queryFn: themeColorsApi.getColors,
});
@@ -39,20 +56,88 @@ export function ThemeTab() {
queryFn: themeColorsApi.getEnabledThemes,
});
// Local draft state
const [draftColors, setDraftColors] = useState<ThemeColors>(DEFAULT_THEME_COLORS);
const savedColorsRef = useRef<ThemeColors>(DEFAULT_THEME_COLORS);
const draftColorsRef = useRef(draftColors);
draftColorsRef.current = draftColors;
// Sync server data into draft and saved snapshot when it arrives
useEffect(() => {
if (serverColors) {
const colors: ThemeColors = {
accent: serverColors.accent,
darkBackground: serverColors.darkBackground,
darkSurface: serverColors.darkSurface,
darkText: serverColors.darkText,
darkTextSecondary: serverColors.darkTextSecondary,
lightBackground: serverColors.lightBackground,
lightSurface: serverColors.lightSurface,
lightText: serverColors.lightText,
lightTextSecondary: serverColors.lightTextSecondary,
success: serverColors.success,
warning: serverColors.warning,
error: serverColors.error,
};
// Only sync if saved snapshot matches current draft (no unsaved changes)
if (
colorsEqual(savedColorsRef.current, draftColorsRef.current) ||
colorsEqual(savedColorsRef.current, DEFAULT_THEME_COLORS)
) {
setDraftColors(colors);
}
savedColorsRef.current = colors;
}
}, [serverColors]);
const hasUnsavedChanges = !colorsEqual(draftColors, savedColorsRef.current);
// Mutations
const updateColorsMutation = useMutation({
mutationFn: themeColorsApi.updateColors,
onSuccess: (data) => {
applyThemeColors(data);
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
const colors: ThemeColors = {
accent: data.accent,
darkBackground: data.darkBackground,
darkSurface: data.darkSurface,
darkText: data.darkText,
darkTextSecondary: data.darkTextSecondary,
lightBackground: data.lightBackground,
lightSurface: data.lightSurface,
lightText: data.lightText,
lightTextSecondary: data.lightTextSecondary,
success: data.success,
warning: data.warning,
error: data.error,
};
savedColorsRef.current = colors;
setDraftColors(colors);
applyThemeColors(colors);
queryClient.setQueryData(['theme-colors'], data);
},
});
const resetColorsMutation = useMutation({
mutationFn: themeColorsApi.resetColors,
onSuccess: (data) => {
applyThemeColors(data);
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
const colors: ThemeColors = {
accent: data.accent,
darkBackground: data.darkBackground,
darkSurface: data.darkSurface,
darkText: data.darkText,
darkTextSecondary: data.darkTextSecondary,
lightBackground: data.lightBackground,
lightSurface: data.lightSurface,
lightText: data.lightText,
lightTextSecondary: data.lightTextSecondary,
success: data.success,
warning: data.warning,
error: data.error,
};
savedColorsRef.current = colors;
setDraftColors(colors);
applyThemeColors(colors);
queryClient.setQueryData(['theme-colors'], data);
},
});
@@ -64,6 +149,42 @@ export function ThemeTab() {
},
});
// Update a single color in the draft and apply preview instantly
const updateDraftColor = useCallback(
(key: keyof ThemeColors, value: string) => {
setDraftColors((prev) => {
const next = { ...prev, [key]: value };
applyThemeColors(next);
queryClient.setQueryData(['theme-colors'], next);
return next;
});
},
[queryClient],
);
// Apply a full preset and auto-save to server
const applyPreset = useCallback(
(colors: Partial<ThemeColors>) => {
setDraftColors((prev) => {
const next = { ...prev, ...colors };
applyThemeColors(next);
queryClient.setQueryData(['theme-colors'], next);
// Auto-save preset to server so it persists across navigation
updateColorsMutation.mutate(next);
return next;
});
},
[queryClient, updateColorsMutation],
);
// Cancel: revert draft to saved
const handleCancel = useCallback(() => {
const saved = savedColorsRef.current;
setDraftColors(saved);
applyThemeColors(saved);
queryClient.setQueryData(['theme-colors'], saved);
}, [queryClient]);
return (
<div className="space-y-6">
{/* Theme toggles */}
@@ -130,8 +251,7 @@ export function ThemeTab() {
{THEME_PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => updateColorsMutation.mutate(preset.colors)}
disabled={updateColorsMutation.isPending}
onClick={() => applyPreset(preset.colors)}
className="rounded-xl border border-dark-600 p-3 transition-all hover:scale-[1.02] hover:border-dark-500"
style={{ backgroundColor: preset.colors.darkBackground }}
>
@@ -189,9 +309,8 @@ export function ThemeTab() {
</h4>
<ColorPicker
label={t('theme.accent')}
value={themeColors?.accent || DEFAULT_THEME_COLORS.accent}
onChange={(color) => updateColorsMutation.mutate({ accent: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.accent}
onChange={(color) => updateDraftColor('accent', color)}
/>
</div>
@@ -203,27 +322,23 @@ export function ThemeTab() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ColorPicker
label={t('admin.settings.colors.background')}
value={themeColors?.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
onChange={(color) => updateColorsMutation.mutate({ darkBackground: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.darkBackground}
onChange={(color) => updateDraftColor('darkBackground', color)}
/>
<ColorPicker
label={t('admin.settings.colors.surface')}
value={themeColors?.darkSurface || DEFAULT_THEME_COLORS.darkSurface}
onChange={(color) => updateColorsMutation.mutate({ darkSurface: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.darkSurface}
onChange={(color) => updateDraftColor('darkSurface', color)}
/>
<ColorPicker
label={t('admin.settings.colors.text')}
value={themeColors?.darkText || DEFAULT_THEME_COLORS.darkText}
onChange={(color) => updateColorsMutation.mutate({ darkText: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.darkText}
onChange={(color) => updateDraftColor('darkText', color)}
/>
<ColorPicker
label={t('admin.settings.colors.textSecondary')}
value={themeColors?.darkTextSecondary || DEFAULT_THEME_COLORS.darkTextSecondary}
onChange={(color) => updateColorsMutation.mutate({ darkTextSecondary: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.darkTextSecondary}
onChange={(color) => updateDraftColor('darkTextSecondary', color)}
/>
</div>
</div>
@@ -236,27 +351,23 @@ export function ThemeTab() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ColorPicker
label={t('admin.settings.colors.background')}
value={themeColors?.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
onChange={(color) => updateColorsMutation.mutate({ lightBackground: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.lightBackground}
onChange={(color) => updateDraftColor('lightBackground', color)}
/>
<ColorPicker
label={t('admin.settings.colors.surface')}
value={themeColors?.lightSurface || DEFAULT_THEME_COLORS.lightSurface}
onChange={(color) => updateColorsMutation.mutate({ lightSurface: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.lightSurface}
onChange={(color) => updateDraftColor('lightSurface', color)}
/>
<ColorPicker
label={t('admin.settings.colors.text')}
value={themeColors?.lightText || DEFAULT_THEME_COLORS.lightText}
onChange={(color) => updateColorsMutation.mutate({ lightText: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.lightText}
onChange={(color) => updateDraftColor('lightText', color)}
/>
<ColorPicker
label={t('admin.settings.colors.textSecondary')}
value={themeColors?.lightTextSecondary || DEFAULT_THEME_COLORS.lightTextSecondary}
onChange={(color) => updateColorsMutation.mutate({ lightTextSecondary: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.lightTextSecondary}
onChange={(color) => updateDraftColor('lightTextSecondary', color)}
/>
</div>
</div>
@@ -269,36 +380,59 @@ export function ThemeTab() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<ColorPicker
label={t('admin.settings.colors.success')}
value={themeColors?.success || DEFAULT_THEME_COLORS.success}
onChange={(color) => updateColorsMutation.mutate({ success: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.success}
onChange={(color) => updateDraftColor('success', color)}
/>
<ColorPicker
label={t('admin.settings.colors.warning')}
value={themeColors?.warning || DEFAULT_THEME_COLORS.warning}
onChange={(color) => updateColorsMutation.mutate({ warning: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.warning}
onChange={(color) => updateDraftColor('warning', color)}
/>
<ColorPicker
label={t('admin.settings.colors.error')}
value={themeColors?.error || DEFAULT_THEME_COLORS.error}
onChange={(color) => updateColorsMutation.mutate({ error: color })}
disabled={updateColorsMutation.isPending}
value={draftColors.error}
onChange={(color) => updateDraftColor('error', color)}
/>
</div>
</div>
{/* Reset button */}
{/* Save / Cancel / Reset buttons */}
<div className="flex flex-wrap items-center gap-3">
{hasUnsavedChanges && (
<>
<button
onClick={() => updateColorsMutation.mutate(draftColors)}
disabled={updateColorsMutation.isPending}
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
>
{updateColorsMutation.isPending
? t('common.saving', t('common.save'))
: t('common.save')}
</button>
<button
onClick={handleCancel}
disabled={updateColorsMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{t('common.cancel')}
</button>
</>
)}
</div>
</div>
)}
</div>
{/* Reset all colors */}
<div className="flex justify-end">
<button
onClick={() => resetColorsMutation.mutate()}
disabled={resetColorsMutation.isPending}
className="rounded-xl bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
className="rounded-xl bg-dark-700 px-4 py-2 text-sm text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
>
{t('admin.settings.resetAllColors')}
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
// Components
export * from './AdminBackButton';
export * from './AdminLayout';
export * from './icons';
export * from './Toggle';
export * from './SettingInput';
@@ -10,7 +9,6 @@ export * from './BrandingTab';
export * from './ThemeTab';
export * from './FavoritesTab';
export * from './SettingsTab';
export * from './SettingsSidebar';
export * from './SettingsMobileTabs';
export * from './SettingsSearch';

View File

@@ -0,0 +1,48 @@
import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking';
export default function BlacklistedScreen() {
const { t } = useTranslation();
const { blacklistedInfo } = useBlockingStore();
return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
<div className="w-full max-w-md text-center">
{/* Icon */}
<div className="mb-8">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<svg
className="h-12 w-12 text-red-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
</div>
</div>
{/* Title */}
<h1 className="mb-4 text-2xl font-bold text-white">{t('blocking.blacklisted.title')}</h1>
{/* Message */}
<p className="mb-6 text-lg text-gray-400">{t('blocking.blacklisted.defaultMessage')}</p>
{/* Reason */}
{blacklistedInfo?.message && (
<div className="mb-6 rounded-xl bg-dark-800/50 p-4">
<p className="mb-1 text-sm text-gray-500">{t('blocking.blacklisted.reason')}:</p>
<p className="text-gray-300">{blacklistedInfo.message}</p>
</div>
)}
<p className="mt-8 text-sm text-gray-500">{t('blocking.blacklisted.contactSupport')}</p>
</div>
</div>
);
}

View File

@@ -1,2 +1,3 @@
export { default as MaintenanceScreen } from './MaintenanceScreen';
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen';
export { default as BlacklistedScreen } from './BlacklistedScreen';

View File

@@ -6,7 +6,7 @@ export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500';
return (
<div className="flex min-h-[50vh] items-center justify-center">
<div className="flex min-h-screen items-center justify-center">
<div
className={`h-10 w-10 border-[3px] ${spinnerColor} animate-spin rounded-full border-t-transparent`}
/>

View File

@@ -0,0 +1,327 @@
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import DOMPurify from 'dompurify';
import type {
AppConfig,
LocalizedText,
RemnawaveAppClient,
RemnawavePlatformData,
RemnawaveButtonClient,
} from '@/types';
import { useTheme } from '@/hooks/useTheme';
import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks';
import type { BlockRendererProps } from './blocks';
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'];
function detectPlatform(): string | null {
if (typeof window === 'undefined' || !navigator?.userAgent) return null;
const ua = navigator.userAgent.toLowerCase();
if (/iphone|ipad|ipod/.test(ua)) return 'ios';
if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android';
if (/macintosh|mac os x/.test(ua)) return 'macos';
if (/windows/.test(ua)) return 'windows';
if (/linux/.test(ua)) return 'linux';
return null;
}
const RENDERERS: Record<string, React.ComponentType<BlockRendererProps>> = {
cards: CardsBlock,
timeline: TimelineBlock,
accordion: AccordionBlock,
minimal: MinimalBlock,
};
const BackIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
);
interface Props {
appConfig: AppConfig;
onOpenDeepLink: (url: string) => void;
isTelegramWebApp: boolean;
onGoBack: () => void;
}
export default function InstallationGuide({
appConfig,
onOpenDeepLink,
isTelegramWebApp,
onGoBack,
}: Props) {
const { t, i18n } = useTranslation();
const { isLight } = useTheme();
const detectedPlatform = useMemo(() => detectPlatform(), []);
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
const [activePlatformKey, setActivePlatformKey] = useState<string | null>(null);
const [selectedApp, setSelectedApp] = useState<RemnawaveAppClient | null>(null);
// --- Helpers ---
const getLocalizedText = useCallback(
(text: LocalizedText | undefined): string => {
if (!text) return '';
const lang = i18n.language || 'en';
return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || '';
},
[i18n.language],
);
const getBaseTranslation = useCallback(
(key: string, i18nKey: string): string => {
const bt = appConfig.baseTranslations;
if (bt && key in bt) {
const text = getLocalizedText(bt[key as keyof typeof bt] as LocalizedText);
if (text) return text;
}
return t(i18nKey);
},
[appConfig.baseTranslations, getLocalizedText, t],
);
const getSvgHtml = useCallback(
(svgKey: string | undefined): string => {
if (!svgKey || !appConfig.svgLibrary?.[svgKey]) return '';
const entry = appConfig.svgLibrary[svgKey];
const raw = typeof entry === 'string' ? entry : entry.svgString;
if (!raw) return '';
return DOMPurify.sanitize(raw, { USE_PROFILES: { svg: true, svgFilters: true } });
},
[appConfig.svgLibrary],
);
// --- Available platforms ---
const availablePlatforms = useMemo(() => {
if (!appConfig.platforms) return [];
const available = platformOrder.filter((key) => {
const data = appConfig.platforms[key] as RemnawavePlatformData | undefined;
return data && data.apps && data.apps.length > 0;
});
if (detectedPlatform && available.includes(detectedPlatform)) {
return [detectedPlatform, ...available.filter((p) => p !== detectedPlatform)];
}
return available;
}, [appConfig.platforms, detectedPlatform]);
// --- Auto-select platform & app ---
useEffect(() => {
if (selectedApp || !availablePlatforms.length) return;
const platform = availablePlatforms[0];
const data = appConfig.platforms[platform] as RemnawavePlatformData | undefined;
if (!data?.apps?.length) return;
const app = data.apps.find((a) => a.featured) || data.apps[0];
if (app) {
setSelectedApp(app);
setActivePlatformKey(platform);
}
}, [appConfig.platforms, availablePlatforms, selectedApp]);
// --- Button renderer (delegates to BlockButtons component) ---
const renderBlockButtons = useCallback(
(buttons: RemnawaveButtonClient[] | undefined, variant: 'light' | 'subtle') => (
<BlockButtons
buttons={buttons}
variant={variant}
isLight={isLight}
subscriptionUrl={appConfig.subscriptionUrl}
hideLink={appConfig.hideLink}
deepLink={selectedApp?.deepLink}
getLocalizedText={getLocalizedText}
getBaseTranslation={getBaseTranslation}
getSvgHtml={getSvgHtml}
onOpenDeepLink={onOpenDeepLink}
/>
),
[
appConfig.subscriptionUrl,
appConfig.hideLink,
selectedApp?.deepLink,
isLight,
getLocalizedText,
getBaseTranslation,
getSvgHtml,
onOpenDeepLink,
],
);
// --- Current platform data ---
const currentPlatformKey = activePlatformKey || availablePlatforms[0];
const currentPlatformData = currentPlatformKey
? (appConfig.platforms[currentPlatformKey] as RemnawavePlatformData | undefined)
: undefined;
const currentPlatformApps = currentPlatformData?.apps || [];
// Platform display name
const getPlatformDisplayName = useCallback(
(key: string): string => {
const data = appConfig.platforms[key] as RemnawavePlatformData | undefined;
if (data?.displayName) {
const name = getLocalizedText(data.displayName);
if (name) return name;
}
if (appConfig.platformNames?.[key]) {
return getLocalizedText(appConfig.platformNames[key]);
}
const fallback: Record<string, string> = {
ios: 'iOS',
android: 'Android',
windows: 'Windows',
macos: 'macOS',
linux: 'Linux',
androidTV: 'Android TV',
appleTV: 'Apple TV',
};
return fallback[key] || key;
},
[appConfig.platforms, appConfig.platformNames, getLocalizedText],
);
// Platform SVG icon for dropdown
const currentPlatformSvg = getSvgHtml(currentPlatformData?.svgIconKey);
// Block renderer
const blockType = appConfig.uiConfig?.installationGuidesBlockType || 'cards';
const Renderer = RENDERERS[blockType] || CardsBlock;
return (
<div className="space-y-6 pb-6">
{/* Header + platform dropdown */}
<div className="flex items-center gap-3">
{!isTelegramWebApp && (
<button
onClick={onGoBack}
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
>
<BackIcon />
</button>
)}
<h2 className="flex-1 text-lg font-bold text-dark-100">
{getBaseTranslation('installationGuideHeader', 'subscription.connection.title')}
</h2>
{availablePlatforms.length > 1 && (
<div className="relative flex items-center">
{currentPlatformSvg && (
<div
className="pointer-events-none absolute left-3 z-10 h-5 w-5 text-dark-400 [&>svg]:h-full [&>svg]:w-full"
dangerouslySetInnerHTML={{ __html: currentPlatformSvg }}
/>
)}
<select
value={currentPlatformKey || ''}
onChange={(e) => {
const newPlatform = e.target.value;
setActivePlatformKey(newPlatform);
const data = appConfig.platforms[newPlatform] as RemnawavePlatformData | undefined;
if (data?.apps?.length) {
const app = data.apps.find((a) => a.featured) || data.apps[0];
if (app) setSelectedApp(app);
}
}}
className={`appearance-none rounded-xl border py-2 pr-8 text-sm font-medium outline-none transition-colors ${
isLight
? 'border-dark-700/60 bg-white/80 text-dark-200 shadow-sm hover:border-dark-600'
: 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600'
} ${currentPlatformSvg ? 'pl-10' : 'pl-4'}`}
>
{availablePlatforms.map((p) => (
<option key={p} value={p}>
{getPlatformDisplayName(p)}
</option>
))}
</select>
<div className="pointer-events-none absolute right-2.5 text-dark-400">
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 9l4-4 4 4M8 15l4 4 4-4" />
</svg>
</div>
</div>
)}
</div>
{/* App chips */}
{currentPlatformApps.length > 0 && (
<div className="flex flex-wrap gap-2">
{currentPlatformApps.map((app, idx) => {
const isSelected = selectedApp?.name === app.name;
const appIconSvg = getSvgHtml(app.svgIconKey);
return (
<button
key={app.name + idx}
onClick={() => setSelectedApp(app)}
className={`relative flex min-w-[calc(50%-0.25rem)] items-center gap-2 overflow-hidden rounded-xl px-4 py-2 text-sm font-medium transition-all active:scale-[0.97] ${
isSelected
? isLight
? 'bg-accent-500/15 text-accent-600 ring-1 ring-accent-500/40'
: 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/40'
: isLight
? 'border border-dark-700/60 bg-white/80 text-dark-200 shadow-sm hover:border-dark-600/50 hover:bg-white'
: 'border border-dark-700/50 bg-dark-800/80 text-dark-200 hover:border-dark-600/50 hover:bg-dark-700/80'
}`}
>
{app.featured && <span className="h-2 w-2 shrink-0 rounded-full bg-amber-400" />}
<span className="relative z-10 truncate">{app.name}</span>
{appIconSvg && (
<div
className="ml-auto h-7 w-7 shrink-0 opacity-30 [&>svg]:h-full [&>svg]:w-full"
dangerouslySetInnerHTML={{ __html: appIconSvg }}
/>
)}
</button>
);
})}
</div>
)}
{/* Tutorial button */}
{appConfig.baseSettings?.isShowTutorialButton && appConfig.baseSettings?.tutorialUrl && (
<a
href={appConfig.baseSettings.tutorialUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-secondary w-full justify-center"
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</svg>
{getBaseTranslation('tutorial', 'subscription.connection.tutorial')}
</a>
)}
{/* Blocks */}
{selectedApp && (
<Renderer
blocks={selectedApp.blocks}
isMobile={isMobile}
isLight={isLight}
getLocalizedText={getLocalizedText}
getSvgHtml={getSvgHtml}
renderBlockButtons={renderBlockButtons}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,77 @@
import { useState } from 'react';
import { getColorGradient } from '@/utils/colorParser';
import { ThemeIcon } from './ThemeIcon';
import type { BlockRendererProps } from './types';
export function AccordionBlock({
blocks,
isMobile,
isLight,
getLocalizedText,
getSvgHtml,
renderBlockButtons,
}: BlockRendererProps) {
const [openIndex, setOpenIndex] = useState<number | null>(0);
return (
<div className="space-y-2">
{blocks.map((block, index) => {
const gradientStyle = getColorGradient(block.svgIconColor || 'cyan', isLight);
const isOpen = openIndex === index;
return (
<div
key={index}
className={`overflow-hidden rounded-2xl border transition-colors ${
isLight
? isOpen
? 'border-accent-500/30 bg-white/80 shadow-sm'
: 'border-dark-700/60 bg-white/60'
: isOpen
? 'border-accent-500/30 bg-dark-800/50'
: 'border-dark-700/50 bg-dark-800/50'
}`}
>
{/* Control */}
<button
onClick={() => setOpenIndex(isOpen ? null : index)}
className="flex w-full items-center gap-3 p-4 text-left"
>
<ThemeIcon
getSvgHtml={getSvgHtml}
svgIconKey={block.svgIconKey}
gradientStyle={gradientStyle}
isMobile={isMobile}
/>
<span className="min-w-0 flex-1 truncate font-semibold text-dark-100">
{getLocalizedText(block.title)}
</span>
<svg
className={`h-[18px] w-[18px] shrink-0 text-dark-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Panel */}
<div
className={`overflow-hidden transition-all duration-200 ${
isOpen ? 'max-h-[600px] opacity-100' : 'max-h-0 opacity-0'
}`}
>
<div className="px-4 pb-4">
<p className="whitespace-pre-line text-sm leading-relaxed text-dark-400">
{getLocalizedText(block.description)}
</p>
{renderBlockButtons(block.buttons, 'light')}
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,159 @@
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { RemnawaveButtonClient, LocalizedText } from '@/types';
// eslint-disable-next-line no-script-url
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'];
function isValidDeepLink(url: string | undefined): boolean {
if (!url) return false;
const lowerUrl = url.toLowerCase().trim();
if (dangerousSchemes.some((s) => lowerUrl.startsWith(s))) return false;
return lowerUrl.includes('://');
}
function isValidExternalUrl(url: string | undefined): boolean {
if (!url) return false;
const lowerUrl = url.toLowerCase().trim();
if (dangerousSchemes.some((s) => lowerUrl.startsWith(s))) return false;
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://');
}
const CopyIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
);
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
);
interface BlockButtonsProps {
buttons: RemnawaveButtonClient[] | undefined;
variant: 'light' | 'subtle';
isLight?: boolean;
subscriptionUrl: string | null;
hideLink?: boolean;
deepLink?: string | null;
getLocalizedText: (text: LocalizedText | undefined) => string;
getBaseTranslation: (key: string, i18nKey: string) => string;
getSvgHtml: (key: string | undefined) => string;
onOpenDeepLink: (url: string) => void;
}
export function BlockButtons({
buttons,
variant,
isLight,
subscriptionUrl,
hideLink,
deepLink,
getLocalizedText,
getBaseTranslation,
getSvgHtml,
onOpenDeepLink,
}: BlockButtonsProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async (url: string) => {
try {
await navigator.clipboard.writeText(url);
} catch {
const textarea = document.createElement('textarea');
textarea.value = url;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, []);
if (!buttons || buttons.length === 0) return null;
const baseClass =
variant === 'light'
? isLight
? 'rounded-xl border border-accent-500/50 px-4 py-2 text-sm font-medium text-accent-600 shadow-sm transition-all hover:bg-accent-500/10'
: 'rounded-xl border border-accent-500/40 px-4 py-2 text-sm font-medium text-accent-400 transition-all hover:bg-accent-500/10'
: isLight
? 'rounded-xl px-3 py-1.5 text-sm font-medium text-dark-300 transition-all hover:bg-dark-700/30'
: 'rounded-xl px-3 py-1.5 text-sm font-medium text-dark-300 transition-all hover:bg-dark-700/50';
return (
<div className="mt-3 flex flex-wrap gap-2">
{buttons.map((btn, idx) => {
const btnText = getLocalizedText(btn.text);
const btnSvg = getSvgHtml(btn.svgIconKey);
const btnIcon = btnSvg ? (
<div
className="h-4 w-4 [&>svg]:h-full [&>svg]:w-full"
dangerouslySetInnerHTML={{ __html: btnSvg }}
/>
) : null;
if (btn.type === 'subscriptionLink') {
const url = btn.resolvedUrl || btn.url || btn.link || deepLink || subscriptionUrl;
if (!url || !isValidDeepLink(url)) return null;
return (
<button
key={idx}
onClick={() => onOpenDeepLink(url)}
className={`flex items-center gap-2 ${baseClass}`}
>
{btnIcon}
{btnText || getBaseTranslation('openApp', 'subscription.connection.openLink')}
</button>
);
}
if (btn.type === 'copyButton') {
if (hideLink) return null;
const url = btn.resolvedUrl || subscriptionUrl;
if (!url) return null;
return (
<button
key={idx}
onClick={() => handleCopy(url)}
className={`flex items-center gap-2 ${
copied
? `rounded-xl border border-success-500 bg-success-500/10 px-4 py-2 text-sm font-medium ${isLight ? 'text-success-600' : 'text-success-400'}`
: baseClass
}`}
>
{copied ? <CheckIcon /> : btnIcon || <CopyIcon />}
{copied
? t('subscription.connection.copied')
: btnText || getBaseTranslation('copyLink', 'subscription.connection.copyLink')}
</button>
);
}
// external
const href = btn.link || btn.url || '';
if (!isValidExternalUrl(href)) return null;
return (
<a
key={idx}
href={href}
target="_blank"
rel="noopener noreferrer"
className={`inline-flex items-center gap-2 ${baseClass}`}
>
{btnIcon}
{btnText}
</a>
);
})}
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { getColorGradient } from '@/utils/colorParser';
import { ThemeIcon } from './ThemeIcon';
import type { BlockRendererProps } from './types';
export function CardsBlock({
blocks,
isMobile,
isLight,
getLocalizedText,
getSvgHtml,
renderBlockButtons,
}: BlockRendererProps) {
return (
<div className="space-y-3">
{blocks.map((block, index) => {
const gradientStyle = getColorGradient(block.svgIconColor || 'cyan', isLight);
return (
<div
key={index}
className={`rounded-2xl border p-4 sm:p-5 ${
isLight
? 'border-dark-700/60 bg-white/80 shadow-sm'
: 'border-dark-700/50 bg-dark-800/50'
}`}
>
<div className="flex items-start gap-3 sm:gap-4">
<ThemeIcon
getSvgHtml={getSvgHtml}
svgIconKey={block.svgIconKey}
gradientStyle={gradientStyle}
isMobile={isMobile}
/>
<div className="min-w-0 flex-1">
<h3 className="font-semibold text-dark-100">{getLocalizedText(block.title)}</h3>
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed text-dark-400">
{getLocalizedText(block.description)}
</p>
{renderBlockButtons(block.buttons, 'light')}
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { getColorGradient } from '@/utils/colorParser';
import { ThemeIcon } from './ThemeIcon';
import type { BlockRendererProps } from './types';
export function MinimalBlock({
blocks,
isMobile,
isLight,
getLocalizedText,
getSvgHtml,
renderBlockButtons,
}: BlockRendererProps) {
return (
<div>
{blocks.map((block, index) => {
const gradientStyle = getColorGradient(block.svgIconColor || 'cyan', isLight);
const isLast = index === blocks.length - 1;
return (
<div
key={index}
className={
isLast
? 'pb-4'
: `mb-4 border-b pb-4 ${isLight ? 'border-dark-700/40' : 'border-dark-700/50'}`
}
>
<div className="mb-2 flex items-center gap-3">
<ThemeIcon
getSvgHtml={getSvgHtml}
svgIconKey={block.svgIconKey}
gradientStyle={gradientStyle}
isMobile={isMobile}
/>
<span className="font-medium text-dark-100">{getLocalizedText(block.title)}</span>
</div>
<p className="whitespace-pre-line text-sm leading-relaxed text-dark-400">
{getLocalizedText(block.description)}
</p>
{renderBlockButtons(block.buttons, 'subtle')}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import type { ColorGradientStyle } from '@/utils/colorParser';
interface ThemeIconProps {
getSvgHtml: (key: string | undefined) => string;
svgIconKey?: string;
gradientStyle: ColorGradientStyle;
isMobile: boolean;
}
export function ThemeIcon({ getSvgHtml, svgIconKey, gradientStyle, isMobile }: ThemeIconProps) {
const svgHtml = getSvgHtml(svgIconKey);
if (!svgHtml) return null;
const size = isMobile ? 36 : 44;
const iconSize = isMobile ? 18 : 22;
return (
<div
className="flex shrink-0 items-center justify-center rounded-full"
style={{
width: size,
height: size,
background: gradientStyle.background,
border: gradientStyle.border,
boxShadow: gradientStyle.boxShadow,
}}
>
<div
style={{ width: iconSize, height: iconSize }}
className="[&>svg]:h-full [&>svg]:w-full"
dangerouslySetInnerHTML={{ __html: svgHtml }}
/>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { getColorGradientSolid } from '@/utils/colorParser';
import { ThemeIcon } from './ThemeIcon';
import type { BlockRendererProps } from './types';
export function TimelineBlock({
blocks,
isMobile,
isLight,
getLocalizedText,
getSvgHtml,
renderBlockButtons,
}: BlockRendererProps) {
return (
<div className="space-y-0">
{blocks.map((block, index) => {
const gradientStyle = getColorGradientSolid(block.svgIconColor || 'cyan', isLight);
const isLast = index === blocks.length - 1;
return (
<div key={index} className="flex gap-3 sm:gap-4">
{/* Left column: bullet + line segment */}
<div className="flex flex-col items-center">
<ThemeIcon
getSvgHtml={getSvgHtml}
svgIconKey={block.svgIconKey}
gradientStyle={gradientStyle}
isMobile={isMobile}
/>
{!isLast && (
<div className={`w-0.5 flex-1 ${isLight ? 'bg-dark-700/40' : 'bg-dark-700'}`} />
)}
</div>
{/* Right column: content */}
<div className={`min-w-0 flex-1 ${isLast ? '' : 'pb-6'}`}>
<h3 className="font-semibold text-dark-100">{getLocalizedText(block.title)}</h3>
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed text-dark-400">
{getLocalizedText(block.description)}
</p>
{renderBlockButtons(block.buttons, 'light')}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { CardsBlock } from './CardsBlock';
export { TimelineBlock } from './TimelineBlock';
export { AccordionBlock } from './AccordionBlock';
export { MinimalBlock } from './MinimalBlock';
export { BlockButtons } from './BlockButtons';
export type { BlockRendererProps } from './types';

View File

@@ -0,0 +1,13 @@
import type { RemnawaveBlockClient, RemnawaveButtonClient, LocalizedText } from '@/types';
export interface BlockRendererProps {
blocks: RemnawaveBlockClient[];
isMobile: boolean;
isLight: boolean;
getLocalizedText: (text: LocalizedText | undefined) => string;
getSvgHtml: (key: string | undefined) => string;
renderBlockButtons: (
buttons: RemnawaveButtonClient[] | undefined,
variant: 'light' | 'subtle',
) => React.ReactNode;
}

View File

@@ -1,56 +0,0 @@
import { forwardRef, type ReactNode } from 'react';
import { motion } from 'framer-motion';
import { cn } from '@/lib/utils';
import { Button, type ButtonProps } from '@/components/primitives/Button';
import { fadeIn, fadeInTransition } from '@/components/motion/transitions';
export interface EmptyStateProps {
icon?: ReactNode;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
variant?: ButtonProps['variant'];
};
className?: string;
}
export const EmptyState = forwardRef<HTMLDivElement, EmptyStateProps>(
({ icon, title, description, action, className }, ref) => {
return (
<motion.div
ref={ref}
className={cn('flex flex-col items-center justify-center py-12 text-center', className)}
variants={fadeIn}
initial="initial"
animate="animate"
transition={fadeInTransition}
>
{/* Icon */}
{icon && (
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-dark-800/50 text-dark-400">
{icon}
</div>
)}
{/* Title */}
<h3 className="text-lg font-semibold text-dark-100">{title}</h3>
{/* Description */}
{description && <p className="mt-2 max-w-sm text-sm text-dark-400">{description}</p>}
{/* Action */}
{action && (
<div className="mt-6">
<Button variant={action.variant || 'primary'} onClick={action.onClick}>
{action.label}
</Button>
</div>
)}
</motion.div>
);
},
);
EmptyState.displayName = 'EmptyState';

View File

@@ -1 +0,0 @@
export { EmptyState, type EmptyStateProps } from './EmptyState';

View File

@@ -1,3 +1,2 @@
export * from './Card';
export * from './StatCard';
export * from './EmptyState';

View File

@@ -1,4 +1,4 @@
import { Link, useLocation } from 'react-router-dom';
import { Link, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { useState, useEffect } from 'react';

View File

@@ -1,26 +1,19 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useLocation, useNavigate, Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useLocation, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '@/store/auth';
import { useBackButton, useHaptic } from '@/platform';
import { useHaptic } from '@/platform';
import { useTelegramSDK } from '@/hooks/useTelegramSDK';
import { useTheme } from '@/hooks/useTheme';
import { useBranding } from '@/hooks/useBranding';
import { useFeatureFlags } from '@/hooks/useFeatureFlags';
import { useScrollRestoration } from '@/hooks/useScrollRestoration';
import { themeColorsApi } from '@/api/themeColors';
import { referralApi } from '@/api/referral';
import { wheelApi } from '@/api/wheel';
import { contestsApi } from '@/api/contests';
import { pollsApi } from '@/api/polls';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
isLogoPreloaded,
} from '@/api/branding';
import { setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK';
import { isLogoPreloaded } from '@/api/branding';
import { cn } from '@/lib/utils';
import { UI } from '@/config/constants';
import WebSocketNotifications from '@/components/WebSocketNotifications';
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
@@ -176,9 +169,6 @@ const MoonIcon = ({ className }: { className?: string }) => (
</svg>
);
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
interface AppShellProps {
children: React.ReactNode;
}
@@ -186,20 +176,17 @@ interface AppShellProps {
export function AppShell({ children }: AppShellProps) {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { isAdmin, isAuthenticated, logout } = useAuthStore();
const {
isFullscreen,
safeAreaInset,
contentSafeAreaInset,
requestFullscreen,
isTelegramWebApp,
platform,
isMobile,
} = useTelegramSDK();
const { isAdmin, logout } = useAuthStore();
const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } =
useTelegramSDK();
const haptic = useHaptic();
const { toggleTheme, isDark } = useTheme();
// Extracted hooks
const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding();
const { referralEnabled, wheelEnabled, hasContests, hasPolls } = useFeatureFlags();
useScrollRestoration();
// Theme toggle visibility
const { data: enabledThemes } = useQuery({
queryKey: ['enabled-themes'],
@@ -214,147 +201,6 @@ export function AppShell({ children }: AppShellProps) {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
// Scroll position restoration for admin pages
const scrollPositions = useRef<Record<string, number>>({});
// Disable browser's automatic scroll restoration
useEffect(() => {
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
}, []);
// Continuously save scroll position for current path
useEffect(() => {
const currentPath = location.pathname;
// Only track scroll for admin pages
if (!currentPath.startsWith('/admin')) return;
const handleScroll = () => {
scrollPositions.current[currentPath] = window.scrollY;
};
// Save on scroll
window.addEventListener('scroll', handleScroll, { passive: true });
// Restore scroll position immediately (synchronous)
const savedPosition = scrollPositions.current[currentPath];
if (savedPosition !== undefined && savedPosition > 0) {
// Immediate restore
window.scrollTo({ top: savedPosition, behavior: 'instant' });
}
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [location.pathname]);
// Branding
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding();
setCachedBranding(data);
preloadLogo(data);
return data;
},
initialData: getCachedBranding() ?? undefined,
staleTime: 60000,
enabled: isAuthenticated,
});
const appName = branding ? branding.name : FALLBACK_NAME;
const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
const hasCustomLogo = branding?.has_custom_logo || false;
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
// Set document title
useEffect(() => {
document.title = appName || 'VPN';
}, [appName]);
// Update favicon
useEffect(() => {
if (!logoUrl) return;
const link =
document.querySelector<HTMLLinkElement>("link[rel*='icon']") ||
document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = logoUrl;
document.head.appendChild(link);
}, [logoUrl]);
// Fullscreen setting from server
const { data: fullscreenSetting } = useQuery({
queryKey: ['fullscreen-enabled'],
queryFn: brandingApi.getFullscreenEnabled,
staleTime: 60000,
});
// Apply fullscreen setting when loaded from server
// Only apply on mobile Telegram (iOS/Android) - desktop doesn't need fullscreen
useEffect(() => {
if (!fullscreenSetting || !isTelegramWebApp) return;
// Update cache for future app starts
setCachedFullscreenEnabled(fullscreenSetting.enabled);
// Request fullscreen if enabled, not already fullscreen, and on mobile Telegram
if (fullscreenSetting.enabled && !isFullscreen && isMobile) {
requestFullscreen();
}
}, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]);
// Feature flags
const { data: referralTerms } = useQuery({
queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: wheelConfig } = useQuery({
queryKey: ['wheel-config'],
queryFn: wheelApi.getConfig,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: contestsCount } = useQuery({
queryKey: ['contests-count'],
queryFn: contestsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: pollsCount } = useQuery({
queryKey: ['polls-count'],
queryFn: pollsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
// BackButton for Telegram Mini App
// Don't show back button on main tab pages (bottom nav) - users navigate via tabs
const mainTabPaths = ['/', '/subscription', '/balance', '/referral', '/support', '/wheel'];
const isMainTabPage = mainTabPaths.includes(location.pathname);
const handleBack = useCallback(() => {
if (mobileMenuOpen) {
setMobileMenuOpen(false);
return;
}
navigate(-1);
}, [mobileMenuOpen, navigate]);
useBackButton(isMainTabPage ? null : handleBack);
// Keyboard detection for hiding bottom nav
useEffect(() => {
const handleFocusIn = (e: FocusEvent) => {
@@ -405,7 +251,8 @@ export function AppShell({ children }: AppShellProps) {
// Calculate header height based on fullscreen mode (only on mobile Telegram)
// On iOS: contentSafeAreaInset.top includes status bar + dynamic island + Telegram header
// On Android: safeAreaInset.top only includes status bar, need to add Telegram header height (~48px)
const telegramHeaderHeight = platform === 'android' ? 48 : 45;
const telegramHeaderHeight =
platform === 'android' ? UI.TELEGRAM_HEADER_ANDROID_PX : UI.TELEGRAM_HEADER_IOS_PX;
const headerHeight = isMobileFullscreen
? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + telegramHeaderHeight
: 64;
@@ -421,7 +268,7 @@ export function AppShell({ children }: AppShellProps) {
{/* Desktop Header */}
<header className="fixed left-0 right-0 top-0 z-50 hidden border-b border-dark-800/50 bg-dark-950/80 backdrop-blur-xl lg:block">
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between px-6">
<div className="mx-auto grid h-14 max-w-6xl grid-cols-[auto_1fr_auto] items-center gap-4 px-6">
{/* Logo */}
<Link to="/" className="flex items-center gap-2.5" onClick={handleNavClick}>
<div className="relative flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-lg bg-dark-800">
@@ -448,7 +295,7 @@ export function AppShell({ children }: AppShellProps) {
</Link>
{/* Center Navigation */}
<nav className="flex items-center gap-1">
<nav className="flex items-center justify-center gap-1">
{desktopNavItems.map((item) => (
<Link
key={item.path}
@@ -465,7 +312,7 @@ export function AppShell({ children }: AppShellProps) {
<span>{item.label}</span>
</Link>
))}
{referralTerms?.is_enabled && (
{referralEnabled && (
<Link
to="/referral"
onClick={handleNavClick}
@@ -502,19 +349,20 @@ export function AppShell({ children }: AppShellProps) {
</nav>
{/* Right side actions */}
<div className="flex items-center gap-2">
{canToggleTheme && (
<div className="flex items-center justify-end gap-2">
<button
onClick={() => {
haptic.impact('light');
toggleTheme();
}}
className="flex h-8 w-8 items-center justify-center rounded-lg text-dark-400 transition-colors hover:bg-dark-800/50 hover:text-dark-200"
className={cn(
'rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-all duration-200 hover:bg-dark-700 hover:text-accent-400',
!canToggleTheme && 'pointer-events-none invisible',
)}
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
>
{isDark ? <MoonIcon className="h-4 w-4" /> : <SunIcon className="h-4 w-4" />}
{isDark ? <MoonIcon className="h-5 w-5" /> : <SunIcon className="h-5 w-5" />}
</button>
)}
<TicketNotificationBell isAdmin={location.pathname.startsWith('/admin')} />
<LanguageSwitcher />
<button
@@ -522,10 +370,10 @@ export function AppShell({ children }: AppShellProps) {
haptic.impact('light');
logout();
}}
className="flex h-8 w-8 items-center justify-center rounded-lg text-dark-400 transition-colors hover:bg-dark-800/50 hover:text-dark-200"
className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-all duration-200 hover:bg-dark-700 hover:text-accent-400"
title={t('nav.logout')}
>
<LogoutIcon className="h-4 w-4" />
<LogoutIcon className="h-5 w-5" />
</button>
</div>
</div>
@@ -541,10 +389,10 @@ export function AppShell({ children }: AppShellProps) {
safeAreaInset={safeAreaInset}
contentSafeAreaInset={contentSafeAreaInset}
telegramPlatform={platform}
wheelEnabled={wheelConfig?.is_enabled}
referralEnabled={referralTerms?.is_enabled}
hasContests={(contestsCount?.count ?? 0) > 0}
hasPolls={(pollsCount?.count ?? 0) > 0}
wheelEnabled={wheelEnabled}
referralEnabled={referralEnabled}
hasContests={hasContests}
hasPolls={hasPolls}
/>
{/* Desktop spacer */}
@@ -559,8 +407,8 @@ export function AppShell({ children }: AppShellProps) {
{/* Mobile Bottom Navigation */}
<MobileBottomNav
isKeyboardOpen={isKeyboardOpen}
referralEnabled={referralTerms?.is_enabled}
wheelEnabled={wheelConfig?.is_enabled}
referralEnabled={referralEnabled}
wheelEnabled={wheelEnabled}
/>
</div>
);

View File

@@ -154,6 +154,14 @@ export function Aurora() {
const background = isDark ? themeColors.darkBackground : themeColors.lightBackground;
const surface = isDark ? themeColors.darkSurface : themeColors.lightSurface;
// Refs for initial color values (WebGL context shouldn't recreate on color change)
const backgroundRef = useRef(background);
backgroundRef.current = background;
const surfaceRef = useRef(surface);
surfaceRef.current = surface;
const accentRef = useRef(themeColors.accent);
accentRef.current = themeColors.accent;
// Initialize WebGL context once (only depends on isEnabled)
useEffect(() => {
if (!isEnabled || !containerRef.current) return;
@@ -175,7 +183,11 @@ export function Aurora() {
const geometry = new Triangle(gl);
const colorStops = generateColorStops(background, surface, themeColors.accent);
const colorStops = generateColorStops(
backgroundRef.current,
surfaceRef.current,
accentRef.current,
);
const colorStopsArray = colorStops
.map((hex) => {
const c = new Color(hex);
@@ -238,7 +250,7 @@ export function Aurora() {
rendererRef.current = null;
programRef.current = null;
};
}, [isEnabled]); // eslint-disable-line react-hooks/exhaustive-deps
}, [isEnabled]);
// Update color uniforms reactively without recreating WebGL context
useEffect(() => {

View File

@@ -1,4 +1,4 @@
import { Link, useLocation } from 'react-router-dom';
import { Link, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';
import { useQuery } from '@tanstack/react-query';

View File

@@ -1,4 +1,4 @@
import { Link, useLocation } from 'react-router-dom';
import { Link, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion';

View File

@@ -1,57 +0,0 @@
import { motion } from 'framer-motion';
/**
* Animated moving gradient background.
* Uses CSS variables for colors to support theme switching.
* Lightweight - pure CSS gradients with Framer Motion animation.
*/
export function MovingGradient() {
return (
<div className="fixed inset-0 -z-10 overflow-hidden">
{/* Base background */}
<div className="absolute inset-0 bg-dark-950" />
{/* Animated gradient layer */}
<motion.div
className="absolute inset-0 opacity-60"
style={{
background:
'radial-gradient(ellipse 80% 50% at 20% 40%, rgba(var(--color-accent-500), 0.12) 0%, transparent 50%), radial-gradient(ellipse 60% 40% at 80% 60%, rgba(var(--color-accent-500), 0.08) 0%, transparent 50%)',
}}
animate={{
backgroundPosition: ['0% 0%', '100% 100%', '0% 0%'],
}}
transition={{
duration: 20,
repeat: Infinity,
ease: 'linear',
}}
/>
{/* Second animated layer - moves in opposite direction */}
<motion.div
className="absolute inset-0 opacity-40"
style={{
background:
'radial-gradient(ellipse 70% 60% at 70% 30%, rgba(var(--color-accent-500), 0.1) 0%, transparent 50%), radial-gradient(ellipse 50% 50% at 30% 70%, rgba(var(--color-accent-500), 0.06) 0%, transparent 50%)',
}}
animate={{
backgroundPosition: ['100% 100%', '0% 0%', '100% 100%'],
}}
transition={{
duration: 25,
repeat: Infinity,
ease: 'linear',
}}
/>
{/* Subtle noise texture overlay */}
<div
className="absolute inset-0 opacity-[0.02]"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E")`,
}}
/>
</div>
);
}

View File

@@ -7,13 +7,6 @@ export const springTransition: Transition = {
damping: 30,
};
// Smooth spring for larger movements
export const smoothSpring: Transition = {
type: 'spring',
stiffness: 300,
damping: 25,
};
// Expo easing curve (Linear-style)
export const easeOutExpo = [0.16, 1, 0.3, 1] as const;
@@ -42,27 +35,6 @@ export const slideUpTransition: Transition = {
ease: easeOutExpo,
};
// Slide down animation (for dropdowns, popovers)
export const slideDown: Variants = {
initial: { opacity: 0, y: -8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0 },
};
// Slide from right (for sheets, sidebars)
export const slideRight: Variants = {
initial: { opacity: 0, x: 20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: 20 },
};
// Slide from left
export const slideLeft: Variants = {
initial: { opacity: 0, x: -20 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -20 },
};
// Scale animation (for modals, dialogs)
export const scale: Variants = {
initial: { opacity: 0, scale: 0.95 },
@@ -92,16 +64,6 @@ export const staggerContainer: Variants = {
},
};
// Fast stagger for dense lists
export const fastStaggerContainer: Variants = {
initial: {},
animate: {
transition: {
staggerChildren: 0.03,
},
},
};
// Stagger item (use with staggerContainer)
// Exit is instant to avoid visual glitches in Telegram Mini App
export const staggerItem: Variants = {

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import { AnimatePresence, motion } from 'framer-motion';
import * as DialogPrimitive from '@radix-ui/react-dialog';

View File

@@ -10,6 +10,12 @@ import {
import { cn } from '@/lib/utils';
import { backdrop, backdropTransition, scale, scaleTransition } from '../../motion/transitions';
export {
Trigger as DialogTrigger,
Portal as DialogPortal,
Close as DialogClose,
} from '@radix-ui/react-dialog';
// Close icon
const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -41,15 +47,6 @@ export const Dialog = ({ children, open, onOpenChange, ...props }: DialogProps)
);
};
// Trigger
export const DialogTrigger = DialogPrimitive.Trigger;
// Portal
export const DialogPortal = DialogPrimitive.Portal;
// Close
export const DialogClose = DialogPrimitive.Close;
// Overlay
export type DialogOverlayProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>;
@@ -86,7 +83,7 @@ export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
const { open } = useContext(DialogContext);
return (
<DialogPortal forceMount>
<DialogPrimitive.Portal forceMount>
<AnimatePresence mode="wait">
{open && (
<>
@@ -131,7 +128,7 @@ export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
</>
)}
</AnimatePresence>
</DialogPortal>
</DialogPrimitive.Portal>
);
},
);

View File

@@ -5,6 +5,15 @@ import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { dropdown, dropdownTransition } from '../../motion/transitions';
export {
Root as DropdownMenu,
Trigger as DropdownMenuTrigger,
Group as DropdownMenuGroup,
Portal as DropdownMenuPortal,
Sub as DropdownMenuSub,
RadioGroup as DropdownMenuRadioGroup,
} from '@radix-ui/react-dropdown-menu';
// Icons
const CheckIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -36,24 +45,6 @@ const DotIcon = () => (
</svg>
);
// Root
export const DropdownMenu = DropdownMenuPrimitive.Root;
// Trigger
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
// Group
export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
// Portal
export const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
// Sub
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
// RadioGroup
export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
// SubTrigger
export interface DropdownMenuSubTriggerProps extends ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.SubTrigger

View File

@@ -4,6 +4,13 @@ import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { cn } from '@/lib/utils';
import { dropdown, dropdownTransition } from '../../motion/transitions';
export {
Root as Popover,
Trigger as PopoverTrigger,
Anchor as PopoverAnchor,
Close as PopoverClose,
} from '@radix-ui/react-popover';
// Close icon
const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -17,18 +24,6 @@ const CloseIcon = () => (
</svg>
);
// Root
export const Popover = PopoverPrimitive.Root;
// Trigger
export const PopoverTrigger = PopoverPrimitive.Trigger;
// Anchor
export const PopoverAnchor = PopoverPrimitive.Anchor;
// Close
export const PopoverClose = PopoverPrimitive.Close;
// Content
export interface PopoverContentProps extends ComponentPropsWithoutRef<
typeof PopoverPrimitive.Content

View File

@@ -5,6 +5,8 @@ import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { dropdown, dropdownTransition } from '../../motion/transitions';
export { Root as Select, Group as SelectGroup } from '@radix-ui/react-select';
// Icons
const ChevronDownIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-dark-400">
@@ -30,9 +32,6 @@ const CheckIcon = () => (
</svg>
);
// Root
export const Select = SelectPrimitive.Root;
// Trigger
export interface SelectTriggerProps extends ComponentPropsWithoutRef<
typeof SelectPrimitive.Trigger
@@ -149,9 +148,6 @@ export const SelectItem = forwardRef<HTMLDivElement, SelectItemProps>(
SelectItem.displayName = 'SelectItem';
// Group
export const SelectGroup = SelectPrimitive.Group;
// Label
export type SelectLabelProps = ComponentPropsWithoutRef<typeof SelectPrimitive.Label>;

View File

@@ -10,7 +10,6 @@ import {
} from 'react';
import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform';
import { useBackButton } from '@/platform';
import {
backdrop,
backdropTransition,
@@ -18,6 +17,12 @@ import {
sheetTransition,
} from '../../motion/transitions';
export {
Trigger as SheetTrigger,
Portal as SheetPortal,
Close as SheetClose,
} from '@radix-ui/react-dialog';
// Close icon
const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
@@ -79,15 +84,6 @@ export const Sheet = ({ children, open, onOpenChange, onClose, ...props }: Sheet
);
};
// Trigger
export const SheetTrigger = DialogPrimitive.Trigger;
// Portal
export const SheetPortal = DialogPrimitive.Portal;
// Close
export const SheetClose = DialogPrimitive.Close;
// Overlay
export type SheetOverlayProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>;
@@ -138,9 +134,6 @@ export const SheetContent = forwardRef<HTMLDivElement, SheetContentProps>(
const { open, onClose } = useContext(SheetContext);
const { haptic } = usePlatform();
const dragControls = useDragControls();
// Back button integration
useBackButton(open ? onClose : null);
const handleDragEnd = useCallback(
(_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => {
const velocity = info.velocity.y;
@@ -157,7 +150,7 @@ export const SheetContent = forwardRef<HTMLDivElement, SheetContentProps>(
);
return (
<SheetPortal forceMount>
<DialogPrimitive.Portal forceMount>
<AnimatePresence mode="wait">
{open && (
<>
@@ -221,7 +214,7 @@ export const SheetContent = forwardRef<HTMLDivElement, SheetContentProps>(
</>
)}
</AnimatePresence>
</SheetPortal>
</DialogPrimitive.Portal>
);
},
);

View File

@@ -4,14 +4,11 @@ import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react
import { cn } from '@/lib/utils';
import { tooltip, tooltipTransition } from '../../motion/transitions';
// Provider - wrap your app with this
export const TooltipProvider = TooltipPrimitive.Provider;
// Root
export const Tooltip = TooltipPrimitive.Root;
// Trigger
export const TooltipTrigger = TooltipPrimitive.Trigger;
export {
Provider as TooltipProvider,
Root as Tooltip,
Trigger as TooltipTrigger,
} from '@radix-ui/react-tooltip';
// Content
export type TooltipContentProps = ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>;
@@ -66,10 +63,10 @@ export const SimpleTooltip = ({
delayDuration = 200,
className,
}: SimpleTooltipProps) => (
<Tooltip delayDuration={delayDuration}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipPrimitive.Root delayDuration={delayDuration}>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<TooltipContent side={side} align={align} className={className}>
{content}
</TooltipContent>
</Tooltip>
</TooltipPrimitive.Root>
);

View File

@@ -1,4 +1,4 @@
import { Link } from 'react-router-dom';
import { Link } from 'react-router';
import { forwardRef, useCallback } from 'react';
import { useHaptic } from '@/platform';

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef, useCallback, useState, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useBackButton } from '@/platform';
import { useHaptic } from '@/platform';
export interface SheetProps {
@@ -105,9 +104,6 @@ export function Sheet({
const haptic = useHaptic();
// BackButton integration
useBackButton(isOpen ? onClose : null);
// Calculate current height based on snap point
const currentHeight = `${snapPoints[currentSnapIndex] * 100}vh`;

View File

@@ -131,69 +131,3 @@ export function Skeleton({
return renderSkeleton(0);
}
// Convenience components for common use cases
export function TextSkeleton({
lines = 1,
className = '',
lastLineWidth = '60%',
}: {
lines?: number;
className?: string;
lastLineWidth?: string;
}) {
return (
<div className={`space-y-2 ${className}`}>
{Array.from({ length: lines }).map((_, i) => (
<Skeleton key={i} variant="text" width={i === lines - 1 ? lastLineWidth : '100%'} />
))}
</div>
);
}
export function AvatarSkeleton({
size = 40,
className = '',
}: {
size?: number;
className?: string;
}) {
return <Skeleton variant="avatar" width={size} height={size} className={className} />;
}
export function CardSkeleton({
count = 1,
className = '',
}: {
count?: number;
className?: string;
}) {
return <Skeleton variant="card" count={count} className={className} />;
}
export function ListSkeleton({
count = 3,
className = '',
}: {
count?: number;
className?: string;
}) {
return (
<div className={`space-y-2 ${className}`}>
<Skeleton variant="list" count={count} />
</div>
);
}
export function BentoSkeleton({
count = 1,
className = '',
}: {
count?: number;
className?: string;
}) {
return <Skeleton variant="bento" count={count} className={className} />;
}
// Default export for backwards compatibility
export default BentoSkeleton;

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useMemo, memo } from 'react';
import { useEffect, useRef, useState, memo } from 'react';
import type { WheelPrize } from '../../api/wheel';
interface FortuneWheelProps {
@@ -24,20 +24,6 @@ const FortuneWheel = memo(function FortuneWheel({
const wheelRef = useRef<SVGGElement>(null);
const accumulatedRotation = useRef(0);
const [displayRotation, setDisplayRotation] = useState(0);
const [lightPhase, setLightPhase] = useState(0);
// Animated lights effect - always running, speed depends on spinning state
useEffect(() => {
// Faster animation when spinning, slower when idle
const interval = setInterval(
() => {
setLightPhase((p) => (p + 1) % 20);
},
isSpinning ? 100 : 300,
); // 100ms when spinning, 300ms when idle
return () => clearInterval(interval);
}, [isSpinning]);
useEffect(() => {
if (isSpinning && targetRotation !== null && wheelRef.current) {
@@ -57,18 +43,6 @@ const FortuneWheel = memo(function FortuneWheel({
}
}, [isSpinning, targetRotation, onSpinComplete]);
// Memoize light pattern calculation
const lightPattern = useMemo(() => {
const numLights = isSpinning ? 3 : 4; // 3 lights when spinning, 4 when idle
return Array.from({ length: 20 }, (_, i) => {
// Calculate distance from current lightPhase
const distance = (i - lightPhase + 20) % 20;
// Light is on if within range [0, numLights)
return distance < numLights;
});
}, [isSpinning, lightPhase]);
if (prizes.length === 0) {
return (
<div className="mx-auto flex aspect-square w-full max-w-md items-center justify-center">
@@ -257,36 +231,53 @@ const FortuneWheel = memo(function FortuneWheel({
strokeWidth="2"
/>
{/* LED lights on outer ring - positioned toward outer edge to avoid bleeding into sectors */}
{/* LED chase animation — pure CSS, no React re-renders */}
<style>
{`
@keyframes ledChase {
0%, 100% { fill: #374151; stroke: #1F2937; }
10%, 30% { fill: #FEF08A; stroke: #FDE047; }
}
@keyframes ledGlow {
0%, 100% { opacity: 0; }
10%, 30% { opacity: 0.4; }
}
.led-dot { animation: ledChase 6s linear infinite; }
.led-glow { opacity: 0; animation: ledGlow 6s linear infinite; }
.led-spinning .led-dot { animation-duration: 2s; }
.led-spinning .led-glow { animation-duration: 2s; }
`}
</style>
<g className={isSpinning ? 'led-spinning' : undefined}>
{Array.from({ length: 20 }).map((_, i) => {
const angle = (i * 18 - 90) * (Math.PI / 180);
const ledRadius = outerRadius + 3;
const dotX = center + ledRadius * Math.cos(angle);
const dotY = center + ledRadius * Math.sin(angle);
const isLit = lightPattern[i] ?? i % 2 === 0;
// Delay as fraction of full cycle — CSS handles speed via animation-duration
const delay = `${(i / 20) * 6}s`;
return (
<g key={`led-${i}`}>
{isLit && (
<circle
className="led-glow"
cx={dotX}
cy={dotY}
r={5}
fill="#FEF08A"
opacity={0.4}
style={{ filter: 'blur(2px)' }}
style={{ filter: 'blur(2px)', animationDelay: delay }}
/>
)}
<circle
className="led-dot"
cx={dotX}
cy={dotY}
r={3.5}
fill={isLit ? '#FEF08A' : '#374151'}
stroke={isLit ? '#FDE047' : '#1F2937'}
strokeWidth="1"
style={{ animationDelay: delay }}
/>
</g>
);
})}
</g>
{/* Rotating wheel group */}
<g

30
src/config/constants.ts Normal file
View File

@@ -0,0 +1,30 @@
// Storage keys
export const STORAGE_KEYS = {
THEME: 'cabinet-theme',
ENABLED_THEMES: 'cabinet-enabled-themes',
FAVORITE_SETTINGS: 'admin_favorite_settings',
USER_THEME_PREFS: 'user_theme_preferences',
} as const;
// WebSocket
export const WS = {
MAX_RECONNECT_ATTEMPTS: 5,
PING_INTERVAL_MS: 25000,
MAX_RECONNECT_DELAY_MS: 30000,
} as const;
// UI
export const UI = {
RESEND_COOLDOWN_SEC: 60,
TELEGRAM_HEADER_ANDROID_PX: 48,
TELEGRAM_HEADER_IOS_PX: 45,
} as const;
// API
export const API = {
TIMEOUT_MS: 30000,
BALANCE_STALE_TIME_MS: 30000,
TRAFFIC_CACHE_MS: 30000,
TRAFFIC_WARN_PERCENT: 70,
TRAFFIC_CRITICAL_PERCENT: 90,
} as const;

View File

@@ -276,7 +276,3 @@ export const COLOR_PRESETS: ColorPreset[] = [
},
},
];
export function getPresetById(id: string): ColorPreset | undefined {
return COLOR_PRESETS.find((preset) => preset.id === id);
}

79
src/hooks/useBranding.ts Normal file
View File

@@ -0,0 +1,79 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/auth';
import { useTelegramSDK, setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
isLogoPreloaded,
} from '@/api/branding';
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
export function useBranding() {
const { isAuthenticated } = useAuthStore();
const { isFullscreen, isTelegramWebApp, requestFullscreen, isMobile } = useTelegramSDK();
// Branding data
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding();
setCachedBranding(data);
preloadLogo(data);
return data;
},
initialData: getCachedBranding() ?? undefined,
staleTime: 60000,
enabled: isAuthenticated,
});
const appName = branding ? branding.name : FALLBACK_NAME;
const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
const hasCustomLogo = branding?.has_custom_logo || false;
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
// Set document title
useEffect(() => {
document.title = appName || 'VPN';
}, [appName]);
// Update favicon
useEffect(() => {
if (!logoUrl) return;
const link =
document.querySelector<HTMLLinkElement>("link[rel*='icon']") ||
document.createElement('link');
link.type = 'image/x-icon';
link.rel = 'shortcut icon';
link.href = logoUrl;
document.head.appendChild(link);
}, [logoUrl]);
// Fullscreen setting from server
const { data: fullscreenSetting } = useQuery({
queryKey: ['fullscreen-enabled'],
queryFn: brandingApi.getFullscreenEnabled,
staleTime: 60000,
});
useEffect(() => {
if (!fullscreenSetting || !isTelegramWebApp) return;
setCachedFullscreenEnabled(fullscreenSetting.enabled);
if (fullscreenSetting.enabled && !isFullscreen && isMobile) {
requestFullscreen();
}
}, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]);
return {
appName,
logoLetter,
hasCustomLogo,
logoUrl,
isLogoPreloaded,
};
}

View File

@@ -1,3 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { currencyApi, type ExchangeRates } from '../api/currency';
@@ -40,7 +41,8 @@ export function useCurrency() {
const currencySymbol = t('common.currency');
// Format amount with currency conversion
const formatAmount = (rubAmount: number, decimals: number = 2): string => {
const formatAmount = useCallback(
(rubAmount: number, decimals: number = 2): string => {
if (isRussian) {
return rubAmount.toFixed(decimals);
}
@@ -58,20 +60,29 @@ export function useCurrency() {
}
return convertedAmount.toFixed(decimals);
};
},
[isRussian, targetCurrency, exchangeRates],
);
// Format amount with currency symbol
const formatWithCurrency = (rubAmount: number, decimals: number = 2): string => {
const formatWithCurrency = useCallback(
(rubAmount: number, decimals: number = 2): string => {
return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
};
},
[formatAmount, currencySymbol],
);
// Format amount with + sign (for earnings/bonuses)
const formatPositive = (rubAmount: number, decimals: number = 2): string => {
const formatPositive = useCallback(
(rubAmount: number, decimals: number = 2): string => {
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
};
},
[formatAmount, currencySymbol],
);
// Get raw converted amount (for calculations)
const convertAmount = (rubAmount: number): number => {
const convertAmount = useCallback(
(rubAmount: number): number => {
if (isRussian) {
return rubAmount;
}
@@ -80,17 +91,23 @@ export function useCurrency() {
targetCurrency as keyof ExchangeRates,
exchangeRates,
);
};
},
[isRussian, targetCurrency, exchangeRates],
);
// Convert from user's currency back to rubles
const convertToRub = (amount: number): number => {
const convertToRub = useCallback(
(amount: number): number => {
if (isRussian) {
return amount;
}
return currencyApi.convertToRub(amount, targetCurrency as keyof ExchangeRates, exchangeRates);
};
},
[isRussian, targetCurrency, exchangeRates],
);
return {
return useMemo(
() => ({
exchangeRates,
targetCurrency,
isRussian,
@@ -100,5 +117,17 @@ export function useCurrency() {
formatPositive,
convertAmount,
convertToRub,
};
}),
[
exchangeRates,
targetCurrency,
isRussian,
currencySymbol,
formatAmount,
formatWithCurrency,
formatPositive,
convertAmount,
convertToRub,
],
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { STORAGE_KEYS } from '../config/constants';
const STORAGE_KEY = 'admin_favorite_settings';
const STORAGE_KEY = STORAGE_KEYS.FAVORITE_SETTINGS;
export function useFavoriteSettings() {
const [favorites, setFavorites] = useState<string[]>(() => {

View File

@@ -0,0 +1,49 @@
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/auth';
import { referralApi } from '@/api/referral';
import { wheelApi } from '@/api/wheel';
import { contestsApi } from '@/api/contests';
import { pollsApi } from '@/api/polls';
export function useFeatureFlags() {
const { isAuthenticated } = useAuthStore();
const { data: referralTerms } = useQuery({
queryKey: ['referral-terms'],
queryFn: referralApi.getReferralTerms,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: wheelConfig } = useQuery({
queryKey: ['wheel-config'],
queryFn: wheelApi.getConfig,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: contestsCount } = useQuery({
queryKey: ['contests-count'],
queryFn: contestsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
const { data: pollsCount } = useQuery({
queryKey: ['polls-count'],
queryFn: pollsApi.getCount,
enabled: isAuthenticated,
staleTime: 60000,
retry: false,
});
return {
referralEnabled: referralTerms?.is_enabled,
wheelEnabled: wheelConfig?.is_enabled,
hasContests: (contestsCount?.count ?? 0) > 0,
hasPolls: (pollsCount?.count ?? 0) > 0,
};
}

View File

@@ -0,0 +1,40 @@
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router';
/**
* Saves and restores scroll position for admin pages.
* Disables browser's automatic scroll restoration.
*/
export function useScrollRestoration() {
const location = useLocation();
const scrollPositions = useRef<Record<string, number>>({});
// Disable browser's automatic scroll restoration
useEffect(() => {
if ('scrollRestoration' in history) {
history.scrollRestoration = 'manual';
}
}, []);
// Save/restore scroll for admin pages
useEffect(() => {
const currentPath = location.pathname;
if (!currentPath.startsWith('/admin')) return;
const handleScroll = () => {
scrollPositions.current[currentPath] = window.scrollY;
};
window.addEventListener('scroll', handleScroll, { passive: true });
const savedPosition = scrollPositions.current[currentPath];
if (savedPosition !== undefined && savedPosition > 0) {
window.scrollTo({ top: savedPosition, behavior: 'instant' });
}
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, [location.pathname]);
}

View File

@@ -1,30 +0,0 @@
import { useCallback } from 'react';
import { useTelegramSDK } from './useTelegramSDK';
/**
* Hook for drag-and-drop operations in Telegram Mini App.
* Note: Vertical swipes are now globally disabled at app init,
* so this hook just provides no-op callbacks for compatibility.
*/
export function useTelegramDnd() {
const { isTelegramWebApp } = useTelegramSDK();
const onDragStart = useCallback(() => {
// No-op: swipes are globally disabled
}, []);
const onDragEnd = useCallback(() => {
// No-op: swipes are globally disabled
}, []);
const onDragCancel = useCallback(() => {
// No-op: swipes are globally disabled
}, []);
return {
onDragStart,
onDragEnd,
onDragCancel,
isTelegramWebApp,
};
}

View File

@@ -1,36 +0,0 @@
/**
* @deprecated This hook is deprecated. Use useTelegramSDK instead.
* This file is kept for backward compatibility and re-exports from useTelegramSDK.
*/
import { useTelegramSDK } from './useTelegramSDK';
// Re-export everything from useTelegramSDK for backward compatibility
export {
getCachedFullscreenEnabled,
setCachedFullscreenEnabled,
isInTelegramWebApp,
isTelegramMobile,
} from './useTelegramSDK';
/**
* @deprecated Use useTelegramSDK instead
* Hook for Telegram WebApp API integration - backward compatible wrapper
*/
export function useTelegramWebApp() {
const sdk = useTelegramSDK();
return {
isTelegramWebApp: sdk.isTelegramWebApp,
isFullscreen: sdk.isFullscreen,
isFullscreenSupported: sdk.isFullscreenSupported,
safeAreaInset: sdk.safeAreaInset,
contentSafeAreaInset: sdk.contentSafeAreaInset,
requestFullscreen: sdk.requestFullscreen,
exitFullscreen: sdk.exitFullscreen,
toggleFullscreen: sdk.toggleFullscreen,
disableVerticalSwipes: sdk.disableVerticalSwipes,
enableVerticalSwipes: sdk.enableVerticalSwipes,
webApp: null,
};
}

View File

@@ -1,11 +1,12 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme';
import { themeColorsApi } from '../api/themeColors';
import { STORAGE_KEYS } from '../config/constants';
type Theme = 'dark' | 'light';
const THEME_KEY = 'cabinet-theme';
const ENABLED_THEMES_KEY = 'cabinet-enabled-themes';
const THEME_KEY = STORAGE_KEYS.THEME;
const ENABLED_THEMES_KEY = STORAGE_KEYS.ENABLED_THEMES;
// Fetch enabled themes from API
async function fetchEnabledThemes(): Promise<EnabledThemes> {
@@ -82,18 +83,21 @@ export function useTheme() {
return enabled.dark ? 'dark' : 'light';
});
const themeRef = useRef(theme);
themeRef.current = theme;
// Fetch enabled themes on mount
useEffect(() => {
fetchEnabledThemes().then((data) => {
setEnabledThemes(data);
setIsLoading(false);
// If current theme is disabled, switch to enabled one
if (!data[theme]) {
if (!data[themeRef.current]) {
const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme);
}
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, []);
// Listen for localStorage changes (when admin updates enabled themes from other tabs)
useEffect(() => {

View File

@@ -2,66 +2,7 @@ import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { themeColorsApi } from '../api/themeColors';
import { ThemeColors, DEFAULT_THEME_COLORS, SHADE_LEVELS, ColorPalette } from '../types/theme';
// Convert hex to RGB values
function hexToRgb(hex: string): { r: number; g: number; b: number } {
// Handle shorthand hex
if (hex.length === 4) {
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
}
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return { r, g, b };
}
// Convert hex to HSL
function hexToHsl(hex: string): { h: number; s: number; l: number } {
const { r, g, b } = hexToRgb(hex);
const rNorm = r / 255;
const gNorm = g / 255;
const bNorm = b / 255;
const max = Math.max(rNorm, gNorm, bNorm);
const min = Math.min(rNorm, gNorm, bNorm);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case rNorm:
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
break;
case gNorm:
h = ((bNorm - rNorm) / d + 2) / 6;
break;
case bNorm:
h = ((rNorm - gNorm) / d + 4) / 6;
break;
}
}
return { h: h * 360, s: s * 100, l: l * 100 };
}
// Convert HSL to RGB values
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
s /= 100;
l /= 100;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color);
};
return { r: f(0), g: f(8), b: f(4) };
}
import { hexToRgb, hexToHsl, hslToRgb } from '../utils/colorConversion';
// Convert RGB to string format for CSS variable
function rgbToString(r: number, g: number, b: number): string {

View File

@@ -7,8 +7,9 @@ import {
DEFAULT_USER_PREFERENCES,
BORDER_RADIUS_VALUES,
} from '../types/theme';
import { STORAGE_KEYS } from '../config/constants';
const STORAGE_KEY = 'user_theme_preferences';
const STORAGE_KEY = STORAGE_KEYS.USER_THEME_PREFS;
/**
* Parse preferences from storage string

View File

@@ -161,6 +161,8 @@
"or": "or",
"registerHint": "To register with email, first log in via Telegram, then link your email in settings.",
"telegramRequired": "Telegram authorization required",
"telegramRetryFailed": "Authorization failed. Close the app and try again.",
"telegramReopenHint": "If the problem persists, close and reopen the app",
"telegramNotConfigured": "Telegram bot is not configured",
"authenticating": "Authenticating...",
"orOpenInApp": "Or open the bot in the app",
@@ -346,7 +348,8 @@
"changeApp": "Change app",
"selectPlatform": "Select platform",
"app": "app",
"apps": "apps"
"apps": "apps",
"tutorial": "Tutorial"
},
"myDevices": "My Devices",
"noDevices": "No connected devices",
@@ -1305,6 +1308,9 @@
"confirmDeleteText": "This action cannot be undone. The tariff will be permanently deleted.",
"confirmDeleteWithSubscriptions": "This tariff has {{count}} active subscriptions. After deletion, users will need to select a new tariff when renewing.",
"deleteSuccess": "Tariff deleted successfully",
"saveOrder": "Save order",
"orderSaved": "Order saved",
"dragToReorder": "Drag to reorder",
"days": "days",
"days_one": "{{count}} day",
"days_other": "{{count}} days",
@@ -2626,6 +2632,12 @@
"hint": "Click check button after subscribing",
"notSubscribed": "You haven't subscribed to the channel yet",
"checkError": "Check failed. Please try again later."
},
"blacklisted": {
"title": "Access Denied",
"defaultMessage": "Your account has been blocked.",
"reason": "Reason",
"contactSupport": "If you believe this is an error, please contact support."
}
}
}

View File

@@ -153,6 +153,8 @@
"or": "یا",
"registerHint": "برای ثبت نام با ایمیل، ابتدا با تلگرام وارد شوید، سپس ایمیل خود را در تنظیمات متصل کنید.",
"telegramRequired": "نیاز به تایید تلگرام",
"telegramRetryFailed": "تایید هویت ناموفق بود. برنامه را ببندید و دوباره باز کنید.",
"telegramReopenHint": "اگر مشکل ادامه دارد، برنامه را ببندید و دوباره باز کنید",
"telegramNotConfigured": "ربات تلگرام پیکربندی نشده",
"authenticating": "در حال تایید هویت...",
"orOpenInApp": "یا ربات را در برنامه باز کنید",
@@ -298,7 +300,8 @@
"changeApp": "تغییر برنامه",
"selectPlatform": "انتخاب پلتفرم",
"app": "برنامه",
"apps": "برنامه"
"apps": "برنامه",
"tutorial": "آموزش"
},
"additionalOptions": {
"title": "گزینه‌های اضافی",
@@ -1011,6 +1014,9 @@
"confirmDeleteText": "این عمل قابل بازگشت نیست. تعرفه برای همیشه حذف می‌شود.",
"confirmDeleteWithSubscriptions": "این تعرفه {{count}} اشتراک فعال دارد. پس از حذف، کاربران هنگام تمدید باید تعرفه جدید انتخاب کنند.",
"deleteSuccess": "تعرفه با موفقیت حذف شد",
"saveOrder": "ذخیره ترتیب",
"orderSaved": "ترتیب ذخیره شد",
"dragToReorder": "برای تغییر ترتیب بکشید",
"days": "روز",
"days_other": "{{count}} روز",
"tabs": {
@@ -2270,6 +2276,12 @@
"hint": "پس از عضویت دکمه بررسی را بزنید",
"notSubscribed": "شما هنوز در کانال عضو نشده‌اید",
"checkError": "بررسی ناموفق بود. لطفاً بعداً دوباره امتحان کنید."
},
"blacklisted": {
"title": "دسترسی ممنوع",
"defaultMessage": "حساب شما مسدود شده است.",
"reason": "دلیل",
"contactSupport": "اگر فکر می‌کنید این اشتباه است، با پشتیبانی تماس بگیرید."
}
}
}

View File

@@ -164,6 +164,8 @@
"or": "или",
"registerHint": "Для регистрации по email сначала авторизуйтесь через Telegram, затем привяжите email в настройках.",
"telegramRequired": "Требуется авторизация через Telegram",
"telegramRetryFailed": "Авторизация не удалась. Закройте приложение и откройте заново.",
"telegramReopenHint": "Если проблема повторяется — закройте и откройте приложение заново",
"telegramNotConfigured": "Telegram бот не настроен",
"authenticating": "Авторизация...",
"orOpenInApp": "Или откройте бота в приложении",
@@ -368,7 +370,8 @@
"changeApp": "Сменить приложение",
"selectPlatform": "Выберите платформу",
"app": "приложение",
"apps": "приложений"
"apps": "приложений",
"tutorial": "Инструкция"
},
"myDevices": "Мои устройства",
"noDevices": "Нет подключенных устройств",
@@ -1823,6 +1826,9 @@
"confirmDeleteText": "Это действие нельзя отменить. Тариф будет удалён навсегда.",
"confirmDeleteWithSubscriptions": "У этого тарифа {{count}} активных подписок. При удалении пользователям потребуется выбрать новый тариф при продлении.",
"deleteSuccess": "Тариф успешно удален",
"saveOrder": "Сохранить порядок",
"orderSaved": "Порядок сохранён",
"dragToReorder": "Перетащите для изменения порядка",
"days": "дней",
"days_one": "{{count}} день",
"days_few": "{{count}} дня",
@@ -3339,6 +3345,12 @@
"hint": "После подписки нажмите кнопку проверки",
"notSubscribed": "Вы ещё не подписались на канал",
"checkError": "Ошибка проверки. Попробуйте позже."
},
"blacklisted": {
"title": "Доступ запрещён",
"defaultMessage": "Ваш аккаунт заблокирован.",
"reason": "Причина",
"contactSupport": "Если вы считаете, что это ошибка, обратитесь в поддержку."
}
}
}

View File

@@ -153,6 +153,8 @@
"or": "或",
"registerHint": "要通过邮箱注册请先通过Telegram登录然后在设置中绑定邮箱。",
"telegramRequired": "需要Telegram授权",
"telegramRetryFailed": "授权失败。请关闭应用后重新打开。",
"telegramReopenHint": "如果问题持续存在,请关闭并重新打开应用",
"telegramNotConfigured": "Telegram机器人未配置",
"authenticating": "正在验证...",
"orOpenInApp": "或在应用中打开机器人",
@@ -298,7 +300,8 @@
"changeApp": "更换应用",
"selectPlatform": "选择平台",
"app": "个应用",
"apps": "个应用"
"apps": "个应用",
"tutorial": "教程"
},
"additionalOptions": {
"title": "附加选项",
@@ -1049,6 +1052,9 @@
"confirmDeleteText": "此操作不可撤销。套餐将被永久删除。",
"confirmDeleteWithSubscriptions": "此套餐有 {{count}} 个活跃订阅。删除后,用户续订时需要选择新套餐。",
"deleteSuccess": "套餐删除成功",
"saveOrder": "保存排序",
"orderSaved": "排序已保存",
"dragToReorder": "拖动以重新排序",
"days": "天",
"days_other": "{{count}} 天",
"tabs": {
@@ -2151,6 +2157,12 @@
"hint": "订阅后点击检查按钮",
"notSubscribed": "您还没有订阅该频道",
"checkError": "检查失败。请稍后重试。"
},
"blacklisted": {
"title": "访问被拒绝",
"defaultMessage": "您的帐户已被封锁。",
"reason": "原因",
"contactSupport": "如果您认为这是错误,请联系客服。"
}
},
"banSystem": {

View File

@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
init,
restoreInitData,
retrieveRawInitData,
mountMiniApp,
miniAppReady,
mountThemeParams,
@@ -19,7 +20,9 @@ import {
requestFullscreen,
isFullscreen,
} from '@telegram-apps/sdk-react';
import { clearStaleSessionIfNeeded } from './utils/token';
import { AppWithNavigator } from './AppWithNavigator';
import { ErrorBoundary } from './components/ErrorBoundary';
import { initLogoPreload } from './api/branding';
import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegramSDK';
import './i18n';
@@ -36,6 +39,9 @@ if (!alreadyInitialized) {
init();
restoreInitData();
// Сбрасываем старые токены если init data изменился (новая сессия Telegram)
clearStaleSessionIfNeeded(retrieveRawInitData() || null);
// Mount components — each in its own try/catch so one failure doesn't block others
try {
mountMiniApp();
@@ -100,8 +106,10 @@ const queryClient = new QueryClient({
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ErrorBoundary level="app">
<QueryClientProvider client={queryClient}>
<AppWithNavigator />
</QueryClientProvider>
</ErrorBoundary>
</React.StrictMode>,
);

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import {
banSystemApi,
@@ -206,6 +206,8 @@ export default function AdminBanSystem() {
const [report, setReport] = useState<BanReportResponse | null>(null);
const [health, setHealth] = useState<BanHealthResponse | null>(null);
const [reportHours, setReportHours] = useState(24);
const reportHoursRef = useRef(reportHours);
reportHoursRef.current = reportHours;
const [settingLoading, setSettingLoading] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -240,19 +242,7 @@ export default function AdminBanSystem() {
const [searchQuery, setSearchQuery] = useState('');
const [actionLoading, setActionLoading] = useState<string | null>(null);
useEffect(() => {
loadStatus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (status?.enabled && status?.configured) {
loadTabData(activeTab);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab, status]);
const loadStatus = async () => {
const loadStatus = useCallback(async () => {
try {
setLoading(true);
const data = await banSystemApi.getStatus();
@@ -265,9 +255,10 @@ export default function AdminBanSystem() {
} finally {
setLoading(false);
}
};
}, [t]);
const loadTabData = async (tab: TabType) => {
const loadTabData = useCallback(
async (tab: TabType) => {
try {
setLoading(true);
setError(null);
@@ -314,7 +305,7 @@ export default function AdminBanSystem() {
break;
}
case 'reports': {
const reportData = await banSystemApi.getReport(reportHours);
const reportData = await banSystemApi.getReport(reportHoursRef.current);
setReport(reportData);
break;
}
@@ -329,7 +320,19 @@ export default function AdminBanSystem() {
} finally {
setLoading(false);
}
};
},
[t],
);
useEffect(() => {
loadStatus();
}, [loadStatus]);
useEffect(() => {
if (status?.enabled && status?.configured) {
loadTabData(activeTab);
}
}, [activeTab, status, loadTabData]);
const handleSearch = async () => {
if (!searchQuery.trim()) {
@@ -403,8 +406,7 @@ export default function AdminBanSystem() {
if (activeTab === 'reports' && status?.enabled) {
loadTabData('reports');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reportHours]);
}, [reportHours, activeTab, status, loadTabData]);
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
@@ -673,7 +675,7 @@ export default function AdminBanSystem() {
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder={t('banSystem.users.searchPlaceholder')}
className="w-full rounded-lg border border-dark-700 bg-dark-800 py-2 pl-10 pr-4 text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none"
className="input pl-10"
/>
</div>
<button
@@ -1331,7 +1333,7 @@ export default function AdminBanSystem() {
min={setting.min_value ?? undefined}
max={setting.max_value ?? undefined}
disabled={!setting.editable || settingLoading === setting.key}
className="w-24 rounded-lg border border-dark-700 bg-dark-900 px-3 py-1.5 text-sm text-dark-100 focus:border-accent-500 focus:outline-none disabled:opacity-50"
className="input w-24"
/>
) : setting.type === 'list' ? (
<div className="flex max-w-xs flex-wrap justify-end gap-1.5">
@@ -1351,7 +1353,7 @@ export default function AdminBanSystem() {
)}
{setting.editable && nodes && setting.key.includes('nodes') && (
<select
className="rounded border border-dark-700 bg-dark-900 px-2 py-1 text-xs text-dark-300 focus:border-accent-500 focus:outline-none"
className="input py-1 text-xs"
onChange={(e) => {
if (e.target.value) {
const currentList = Array.isArray(setting.value)

View File

@@ -1,5 +1,5 @@
import { useState, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -10,7 +10,6 @@ import {
CombinedBroadcastCreateRequest,
} from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin';
import { useBackButton } from '../platform/hooks/useBackButton';
// Icons
const BroadcastIcon = () => (
@@ -119,8 +118,6 @@ export default function AdminBroadcastCreate() {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
useBackButton(() => navigate('/admin/broadcasts'));
// Channel selection
const [channel, setChannel] = useState<BroadcastChannel>('telegram');

View File

@@ -1,9 +1,8 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router';
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 { useBackButton } from '../platform/hooks/useBackButton';
// Icons
@@ -159,8 +158,6 @@ export default function AdminBroadcastDetail() {
const broadcastId = id ? parseInt(id, 10) : null;
useBackButton(() => navigate('/admin/broadcasts'));
// Fetch broadcast details
const {
data: broadcast,

View File

@@ -1,9 +1,8 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminBroadcastsApi } from '../api/adminBroadcasts';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
// Icons
@@ -131,9 +130,6 @@ export default function AdminBroadcasts() {
const navigate = useNavigate();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
const [page, setPage] = useState(0);
const limit = 20;

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -11,8 +11,6 @@ import {
} from '../api/campaigns';
import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
import { useBackButton } from '../platform/hooks/useBackButton';
// Icons
const CampaignIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -156,8 +154,6 @@ export default function AdminCampaignCreate() {
const navigate = useNavigate();
const queryClient = useQueryClient();
useBackButton(() => navigate('/admin/campaigns'));
// Form state
const [name, setName] = useState('');
const [startParameter, setStartParameter] = useState('');
@@ -228,7 +224,10 @@ export default function AdminCampaignCreate() {
createMutation.mutate(data);
};
const isValid = name.trim() && startParameter.trim() && /^[a-zA-Z0-9_-]+$/.test(startParameter);
const isNameValid = name.trim().length > 0;
const isStartParamValid =
startParameter.trim().length > 0 && /^[a-zA-Z0-9_-]+$/.test(startParameter);
const isValid = isNameValid && isStartParamValid;
return (
<div className="space-y-6">
@@ -254,27 +253,36 @@ export default function AdminCampaignCreate() {
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.name')}
<span className="text-error-400">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="input"
className={`input ${name.length > 0 && !isNameValid ? 'border-error-500/50' : ''}`}
placeholder={t('admin.campaigns.form.namePlaceholder')}
maxLength={255}
/>
{name.length > 0 && !isNameValid && (
<p className="mt-1 text-xs text-error-400">
{t('admin.campaigns.validation.nameRequired')}
</p>
)}
</div>
{/* Start Parameter */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.startParameter')}
<span className="text-error-400">*</span>
</label>
<input
type="text"
value={startParameter}
onChange={(e) => setStartParameter(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))}
className="input font-mono"
className={`input font-mono ${startParameter.length > 0 && !isStartParamValid ? 'border-error-500/50' : ''}`}
placeholder="instagram_jan2024"
maxLength={100}
/>
<p className="mt-1 text-xs text-dark-500">
{t('admin.campaigns.form.startParameterHint')}
@@ -358,7 +366,7 @@ export default function AdminCampaignCreate() {
<div className="grid grid-cols-3 gap-4">
<div>
<label className="mb-2 block text-sm text-dark-400">
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.days')}
</label>
<input
@@ -370,7 +378,7 @@ export default function AdminCampaignCreate() {
/>
</div>
<div>
<label className="mb-2 block text-sm text-dark-400">
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.trafficGb')}
</label>
<input
@@ -382,7 +390,7 @@ export default function AdminCampaignCreate() {
/>
</div>
<div>
<label className="mb-2 block text-sm text-dark-400">
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.devices')}
</label>
<input

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -12,7 +12,6 @@ import {
import { AdminBackButton } from '../components/admin';
import { CheckIcon, CampaignIcon } from '../components/icons';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
import { useBackButton } from '../platform/hooks/useBackButton';
// Bonus type config
const bonusTypeConfig: Record<
@@ -133,8 +132,6 @@ export default function AdminCampaignEdit() {
const campaignId = parseInt(id || '0');
useBackButton(() => navigate('/admin/campaigns'));
// Fetch campaign
const {
data: campaign,
@@ -233,7 +230,10 @@ export default function AdminCampaignEdit() {
updateMutation.mutate(data);
};
const isValid = name.trim() && startParameter.trim() && /^[a-zA-Z0-9_-]+$/.test(startParameter);
const isNameValid = name.trim().length > 0;
const isStartParamValid =
startParameter.trim().length > 0 && /^[a-zA-Z0-9_-]+$/.test(startParameter);
const isValid = isNameValid && isStartParamValid;
if (isLoading) {
return (
@@ -289,27 +289,36 @@ export default function AdminCampaignEdit() {
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.name')}
<span className="text-error-400">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="input"
className={`input ${name.length > 0 && !isNameValid ? 'border-error-500/50' : ''}`}
placeholder={t('admin.campaigns.form.namePlaceholder')}
maxLength={255}
/>
{name.length > 0 && !isNameValid && (
<p className="mt-1 text-xs text-error-400">
{t('admin.campaigns.validation.nameRequired')}
</p>
)}
</div>
{/* Start Parameter */}
<div>
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.startParameter')}
<span className="text-error-400">*</span>
</label>
<input
type="text"
value={startParameter}
onChange={(e) => setStartParameter(e.target.value.replace(/[^a-zA-Z0-9_-]/g, ''))}
className="input font-mono"
className={`input font-mono ${startParameter.length > 0 && !isStartParamValid ? 'border-error-500/50' : ''}`}
placeholder="instagram_jan2024"
maxLength={100}
/>
<p className="mt-1 text-xs text-dark-500">
{t('admin.campaigns.form.startParameterHint')}
@@ -393,7 +402,7 @@ export default function AdminCampaignEdit() {
<div className="grid grid-cols-3 gap-4">
<div>
<label className="mb-2 block text-sm text-dark-400">
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.days')}
</label>
<input
@@ -405,7 +414,7 @@ export default function AdminCampaignEdit() {
/>
</div>
<div>
<label className="mb-2 block text-sm text-dark-400">
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.trafficGb')}
</label>
<input
@@ -417,7 +426,7 @@ export default function AdminCampaignEdit() {
/>
</div>
<div>
<label className="mb-2 block text-sm text-dark-400">
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.campaigns.form.devices')}
</label>
<input

View File

@@ -1,10 +1,9 @@
import { useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useParams, useNavigate, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { campaignsApi, CampaignBonusType } from '../api/campaigns';
import { AdminBackButton } from '../components/admin';
import { useBackButton } from '../platform/hooks/useBackButton';
// Icons
const CopyIcon = () => (
@@ -98,8 +97,6 @@ export default function AdminCampaignStats() {
const [copied, setCopied] = useState(false);
const [showUsers, setShowUsers] = useState(false);
useBackButton(() => navigate('/admin/campaigns'));
// Fetch stats
const {
data: stats,

View File

@@ -1,11 +1,10 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { campaignsApi, CampaignListItem, CampaignBonusType } from '../api/campaigns';
import { PlusIcon, EditIcon, TrashIcon, CheckIcon, XIcon, ChartIcon } from '../components/icons';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
// Bonus type labels and colors
@@ -67,9 +66,6 @@ export default function AdminCampaigns() {
const queryClient = useQueryClient();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
// Queries

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next';
import {
statsApi,
@@ -10,7 +10,6 @@ import {
type RecentPaymentsResponse,
} from '../api/admin';
import { useCurrency } from '../hooks/useCurrency';
import { useBackButton } from '../platform/hooks/useBackButton';
import { usePlatform } from '../platform/hooks/usePlatform';
// Icons - styled like main navigation
@@ -365,9 +364,6 @@ export default function AdminDashboard() {
const { formatAmount, currencySymbol } = useCurrency();
const { capabilities } = usePlatform();
// Use native Telegram back button in Mini App
useBackButton(() => navigate('/admin'));
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -380,7 +376,7 @@ export default function AdminDashboard() {
const [payments, setPayments] = useState<RecentPaymentsResponse | null>(null);
const [referrersTab, setReferrersTab] = useState<'earnings' | 'invited'>('earnings');
const fetchStats = async () => {
const fetchStats = useCallback(async () => {
try {
setLoading(true);
setError(null);
@@ -392,9 +388,9 @@ export default function AdminDashboard() {
} finally {
setLoading(false);
}
};
}, [t]);
const fetchExtendedStats = async () => {
const fetchExtendedStats = useCallback(async () => {
try {
const [referrersData, campaignsData, paymentsData] = await Promise.all([
statsApi.getTopReferrers(10),
@@ -407,7 +403,7 @@ export default function AdminDashboard() {
} catch (err) {
console.error('Failed to load extended stats:', err);
}
};
}, []);
useEffect(() => {
fetchStats();
@@ -418,8 +414,7 @@ export default function AdminDashboard() {
fetchExtendedStats();
}, 30000);
return () => clearInterval(interval);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [fetchStats, fetchExtendedStats]);
const handleRestartNode = async (uuid: string) => {
try {

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router';
import { useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminEmailTemplatesApi } from '../api/adminEmailTemplates';
@@ -23,7 +23,11 @@ export default function AdminEmailTemplatePreview() {
const state = location.state as PreviewState | null;
// Preview mutation
const previewMutation = useMutation({
const {
mutate: loadPreview,
isPending: previewPending,
isError: previewError,
} = useMutation({
mutationFn: () => {
if (!type || !lang || !state) {
throw new Error('Missing required data');
@@ -45,9 +49,8 @@ export default function AdminEmailTemplatePreview() {
navigate('/admin/email-templates');
return;
}
previewMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type, lang]);
loadPreview();
}, [type, lang, state, navigate, loadPreview]);
// Write preview HTML into iframe
useEffect(() => {
@@ -85,11 +88,11 @@ export default function AdminEmailTemplatePreview() {
{/* Preview content */}
<div className="flex-1 overflow-hidden rounded-xl border border-dark-700 bg-white">
{previewMutation.isPending ? (
{previewPending ? (
<div className="flex h-full items-center justify-center bg-dark-800">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div>
) : previewMutation.isError ? (
) : previewError ? (
<div className="flex h-full flex-col items-center justify-center gap-4 bg-dark-800">
<p className="text-dark-400">{t('common.error')}</p>
<button

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -169,10 +169,6 @@ function TemplateEditor({
const [editSubject, setEditSubject] = useState('');
const [editBody, setEditBody] = useState('');
const [isDirty, setIsDirty] = useState(false);
const [toast, setToast] = useState<{
type: 'success' | 'error' | 'info';
message: string;
} | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang];
@@ -186,11 +182,6 @@ function TemplateEditor({
}
}, [activeLang, langData]);
const showToast = useCallback((type: 'success' | 'error' | 'info', message: string) => {
setToast({ type, message });
setTimeout(() => setToast(null), 3000);
}, []);
// Extract body content from full HTML (strip base template wrapper)
const extractBodyContent = useCallback((html: string): string => {
// If it's wrapped in the base template, extract just the content div
@@ -230,10 +221,10 @@ function TemplateEditor({
queryKey: ['admin', 'email-template', detail.notification_type],
});
setIsDirty(false);
showToast('success', t('admin.emailTemplates.saved'));
notify.success(t('admin.emailTemplates.saved'));
},
onError: () => {
showToast('error', t('common.error'));
notify.error(t('common.error'));
},
});
@@ -246,10 +237,10 @@ function TemplateEditor({
queryKey: ['admin', 'email-template', detail.notification_type],
});
setIsDirty(false);
showToast('success', t('admin.emailTemplates.resetted'));
notify.success(t('admin.emailTemplates.resetted'));
},
onError: () => {
showToast('error', t('common.error'));
notify.error(t('common.error'));
},
});
@@ -260,10 +251,10 @@ function TemplateEditor({
language: activeLang,
}),
onSuccess: (data) => {
showToast('success', `${t('admin.emailTemplates.testSent')}${data.sent_to}`);
notify.success(`${t('admin.emailTemplates.testSent')}${data.sent_to}`);
},
onError: () => {
showToast('error', t('common.error'));
notify.error(t('common.error'));
},
});
@@ -334,14 +325,14 @@ function TemplateEditor({
{/* Subject */}
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-300">
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.emailTemplates.subject')}
</label>
<input
type="text"
value={editSubject}
onChange={(e) => handleSubjectChange(e.target.value)}
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500"
className="input"
placeholder={t('admin.emailTemplates.subjectPlaceholder')}
/>
</div>
@@ -360,7 +351,7 @@ function TemplateEditor({
title={t('admin.emailTemplates.clickToCopy')}
onClick={() => {
navigator.clipboard.writeText(`{${v}}`);
showToast('success', `Copied {${v}}`);
notify.success(`Copied {${v}}`);
}}
>
{`{${v}}`}
@@ -372,7 +363,7 @@ function TemplateEditor({
{/* Body HTML editor */}
<div>
<label className="mb-1.5 block text-xs font-medium text-dark-300">
<label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.emailTemplates.body')}
</label>
<textarea
@@ -380,7 +371,7 @@ function TemplateEditor({
value={editBody}
onChange={(e) => handleBodyChange(e.target.value)}
rows={12}
className="min-h-[200px] w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 font-mono text-xs leading-relaxed text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 sm:min-h-[300px] sm:text-sm"
className="input min-h-[200px] resize-y font-mono text-xs leading-relaxed sm:min-h-[300px] sm:text-sm"
placeholder="<h2>Title</h2><p>Content...</p>"
spellCheck={false}
/>
@@ -448,21 +439,6 @@ function TemplateEditor({
)}
</div>
</div>
{/* Toast */}
{toast && (
<div
className={`fixed left-4 right-4 top-32 z-[100] mx-auto max-w-sm animate-fade-in rounded-xl px-4 py-3 text-center text-sm font-medium shadow-lg sm:left-1/2 sm:right-auto sm:-translate-x-1/2 ${
toast.type === 'success'
? 'bg-success-500 text-white'
: toast.type === 'info'
? 'bg-dark-700 text-dark-100 ring-1 ring-dark-600'
: 'bg-error-500 text-white'
}`}
>
{toast.message}
</div>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More