mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
15
.github/ISSUE_TEMPLATE/bug-bedolage.md
vendored
15
.github/ISSUE_TEMPLATE/bug-bedolage.md
vendored
@@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Bug Bedolage
|
||||
about: Создать баг-репорт «Бедолаге»
|
||||
title: "[BUG] Заголовок"
|
||||
title: '[BUG] Заголовок'
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
@@ -19,15 +18,15 @@ assignees: ''
|
||||
|
||||
## Шаги для воспроизведения
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Фактический результат
|
||||
|
||||
*
|
||||
*
|
||||
*
|
||||
-
|
||||
-
|
||||
-
|
||||
|
||||
### Скрины поведения
|
||||
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/feat-bedolage.md
vendored
3
.github/ISSUE_TEMPLATE/feat-bedolage.md
vendored
@@ -1,10 +1,9 @@
|
||||
---
|
||||
name: Feat Bedolage
|
||||
about: Создать запрос на фичу «Бедолаге»
|
||||
title: "[FEAT] Заголовок"
|
||||
title: '[FEAT] Заголовок'
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
## Описание / Конфигурация
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -46,3 +46,5 @@ miniapp/
|
||||
|
||||
# AI & Agents context
|
||||
.ai/
|
||||
CLAUDE.md
|
||||
**/CLAUDE.md
|
||||
|
||||
146
CHANGELOG.md
146
CHANGELOG.md
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
);
|
||||
|
||||
11
index.html
11
index.html
@@ -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
854
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -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"
|
||||
|
||||
@@ -3,4 +3,4 @@ export default {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
23
src/App.tsx
23
src/App.tsx
@@ -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
|
||||
|
||||
@@ -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,17 +67,19 @@ export function AppWithNavigator() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
{isTelegram && <TelegramBackButton />}
|
||||
<PlatformProvider>
|
||||
<ThemeColorsProvider>
|
||||
<TooltipProvider>
|
||||
<ToastProvider>
|
||||
<WebSocketProvider>
|
||||
<App />
|
||||
</WebSocketProvider>
|
||||
</ToastProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeColorsProvider>
|
||||
</PlatformProvider>
|
||||
<ErrorBoundary level="page">
|
||||
<PlatformProvider>
|
||||
<ThemeColorsProvider>
|
||||
<TooltipProvider>
|
||||
<ToastProvider>
|
||||
<WebSocketProvider>
|
||||
<App />
|
||||
</WebSocketProvider>
|
||||
</ToastProvider>
|
||||
</TooltipProvider>
|
||||
</ThemeColorsProvider>
|
||||
</PlatformProvider>
|
||||
</ErrorBoundary>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,31 +58,53 @@ 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) => {
|
||||
let token = tokenStorage.getAccessToken();
|
||||
// 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();
|
||||
|
||||
// Проверяем срок действия токена перед запросом
|
||||
if (token && isTokenExpired(token)) {
|
||||
// Используем централизованный менеджер для refresh
|
||||
const newToken = await tokenRefreshManager.refreshAccessToken();
|
||||
if (newToken) {
|
||||
token = newToken;
|
||||
} else {
|
||||
// Refresh не удался - редирект на логин
|
||||
tokenStorage.clearTokens();
|
||||
safeRedirectToLogin();
|
||||
return config;
|
||||
// Проверяем срок действия токена перед запросом
|
||||
if (token && isTokenExpired(token)) {
|
||||
// Используем централизованный менеджер для refresh
|
||||
const newToken = await tokenRefreshManager.refreshAccessToken();
|
||||
if (newToken) {
|
||||
token = newToken;
|
||||
} else {
|
||||
// Refresh не удался - редирект на логин
|
||||
tokenStorage.clearTokens();
|
||||
safeRedirectToLogin();
|
||||
return config;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
const telegramInitData = getTelegramInitData();
|
||||
@@ -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 - пусть ошибка дойдет до компонента
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
93
src/components/ErrorBoundary.tsx
Normal file
93
src/components/ErrorBoundary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
onClose();
|
||||
}
|
||||
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`,
|
||||
}}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { usePlatform } from '@/platform';
|
||||
import { BackIcon } from './icons';
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
<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"
|
||||
>
|
||||
{t('admin.settings.resetAllColors')}
|
||||
</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-sm text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
{t('admin.settings.resetAllColors')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
48
src/components/blocking/BlacklistedScreen.tsx
Normal file
48
src/components/blocking/BlacklistedScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as MaintenanceScreen } from './MaintenanceScreen';
|
||||
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen';
|
||||
export { default as BlacklistedScreen } from './BlacklistedScreen';
|
||||
|
||||
@@ -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`}
|
||||
/>
|
||||
|
||||
327
src/components/connection/InstallationGuide.tsx
Normal file
327
src/components/connection/InstallationGuide.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/components/connection/blocks/AccordionBlock.tsx
Normal file
77
src/components/connection/blocks/AccordionBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
src/components/connection/blocks/BlockButtons.tsx
Normal file
159
src/components/connection/blocks/BlockButtons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/components/connection/blocks/CardsBlock.tsx
Normal file
47
src/components/connection/blocks/CardsBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/components/connection/blocks/MinimalBlock.tsx
Normal file
46
src/components/connection/blocks/MinimalBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
src/components/connection/blocks/ThemeIcon.tsx
Normal file
34
src/components/connection/blocks/ThemeIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/components/connection/blocks/TimelineBlock.tsx
Normal file
46
src/components/connection/blocks/TimelineBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/components/connection/blocks/index.ts
Normal file
6
src/components/connection/blocks/index.ts
Normal 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';
|
||||
13
src/components/connection/blocks/types.ts
Normal file
13
src/components/connection/blocks/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -1 +0,0 @@
|
||||
export { EmptyState, type EmptyStateProps } from './EmptyState';
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './Card';
|
||||
export * from './StatCard';
|
||||
export * from './EmptyState';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 && (
|
||||
<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"
|
||||
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" />}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
haptic.impact('light');
|
||||
toggleTheme();
|
||||
}}
|
||||
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-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>
|
||||
);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link } from 'react-router';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
import { useHaptic } from '@/platform';
|
||||
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */}
|
||||
{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;
|
||||
return (
|
||||
<g key={`led-${i}`}>
|
||||
{isLit && (
|
||||
{/* 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);
|
||||
// Delay as fraction of full cycle — CSS handles speed via animation-duration
|
||||
const delay = `${(i / 20) * 6}s`;
|
||||
return (
|
||||
<g key={`led-${i}`}>
|
||||
<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
|
||||
cx={dotX}
|
||||
cy={dotY}
|
||||
r={3.5}
|
||||
fill={isLit ? '#FEF08A' : '#374151'}
|
||||
stroke={isLit ? '#FDE047' : '#1F2937'}
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<circle
|
||||
className="led-dot"
|
||||
cx={dotX}
|
||||
cy={dotY}
|
||||
r={3.5}
|
||||
strokeWidth="1"
|
||||
style={{ animationDelay: delay }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Rotating wheel group */}
|
||||
<g
|
||||
|
||||
30
src/config/constants.ts
Normal file
30
src/config/constants.ts
Normal 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;
|
||||
@@ -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
79
src/hooks/useBranding.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,65 +41,93 @@ export function useCurrency() {
|
||||
const currencySymbol = t('common.currency');
|
||||
|
||||
// Format amount with currency conversion
|
||||
const formatAmount = (rubAmount: number, decimals: number = 2): string => {
|
||||
if (isRussian) {
|
||||
return rubAmount.toFixed(decimals);
|
||||
}
|
||||
const formatAmount = useCallback(
|
||||
(rubAmount: number, decimals: number = 2): string => {
|
||||
if (isRussian) {
|
||||
return rubAmount.toFixed(decimals);
|
||||
}
|
||||
|
||||
// Convert to target currency
|
||||
const convertedAmount = currencyApi.convertFromRub(
|
||||
rubAmount,
|
||||
targetCurrency as keyof ExchangeRates,
|
||||
exchangeRates,
|
||||
);
|
||||
// Convert to target currency
|
||||
const convertedAmount = currencyApi.convertFromRub(
|
||||
rubAmount,
|
||||
targetCurrency as keyof ExchangeRates,
|
||||
exchangeRates,
|
||||
);
|
||||
|
||||
// For IRR (Iranian Toman), use no decimals as amounts are large
|
||||
if (targetCurrency === 'IRR') {
|
||||
return Math.round(convertedAmount).toLocaleString('fa-IR');
|
||||
}
|
||||
// For IRR (Iranian Toman), use no decimals as amounts are large
|
||||
if (targetCurrency === 'IRR') {
|
||||
return Math.round(convertedAmount).toLocaleString('fa-IR');
|
||||
}
|
||||
|
||||
return convertedAmount.toFixed(decimals);
|
||||
};
|
||||
return convertedAmount.toFixed(decimals);
|
||||
},
|
||||
[isRussian, targetCurrency, exchangeRates],
|
||||
);
|
||||
|
||||
// Format amount with currency symbol
|
||||
const formatWithCurrency = (rubAmount: number, decimals: number = 2): string => {
|
||||
return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
|
||||
};
|
||||
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 => {
|
||||
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
|
||||
};
|
||||
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 => {
|
||||
if (isRussian) {
|
||||
return rubAmount;
|
||||
}
|
||||
return currencyApi.convertFromRub(
|
||||
rubAmount,
|
||||
targetCurrency as keyof ExchangeRates,
|
||||
exchangeRates,
|
||||
);
|
||||
};
|
||||
const convertAmount = useCallback(
|
||||
(rubAmount: number): number => {
|
||||
if (isRussian) {
|
||||
return rubAmount;
|
||||
}
|
||||
return currencyApi.convertFromRub(
|
||||
rubAmount,
|
||||
targetCurrency as keyof ExchangeRates,
|
||||
exchangeRates,
|
||||
);
|
||||
},
|
||||
[isRussian, targetCurrency, exchangeRates],
|
||||
);
|
||||
|
||||
// Convert from user's currency back to rubles
|
||||
const convertToRub = (amount: number): number => {
|
||||
if (isRussian) {
|
||||
return amount;
|
||||
}
|
||||
return currencyApi.convertToRub(amount, targetCurrency as keyof ExchangeRates, exchangeRates);
|
||||
};
|
||||
const convertToRub = useCallback(
|
||||
(amount: number): number => {
|
||||
if (isRussian) {
|
||||
return amount;
|
||||
}
|
||||
return currencyApi.convertToRub(amount, targetCurrency as keyof ExchangeRates, exchangeRates);
|
||||
},
|
||||
[isRussian, targetCurrency, exchangeRates],
|
||||
);
|
||||
|
||||
return {
|
||||
exchangeRates,
|
||||
targetCurrency,
|
||||
isRussian,
|
||||
currencySymbol,
|
||||
formatAmount,
|
||||
formatWithCurrency,
|
||||
formatPositive,
|
||||
convertAmount,
|
||||
convertToRub,
|
||||
};
|
||||
return useMemo(
|
||||
() => ({
|
||||
exchangeRates,
|
||||
targetCurrency,
|
||||
isRussian,
|
||||
currencySymbol,
|
||||
formatAmount,
|
||||
formatWithCurrency,
|
||||
formatPositive,
|
||||
convertAmount,
|
||||
convertToRub,
|
||||
}),
|
||||
[
|
||||
exchangeRates,
|
||||
targetCurrency,
|
||||
isRussian,
|
||||
currencySymbol,
|
||||
formatAmount,
|
||||
formatWithCurrency,
|
||||
formatPositive,
|
||||
convertAmount,
|
||||
convertToRub,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[]>(() => {
|
||||
|
||||
49
src/hooks/useFeatureFlags.ts
Normal file
49
src/hooks/useFeatureFlags.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
40
src/hooks/useScrollRestoration.ts
Normal file
40
src/hooks/useScrollRestoration.ts
Normal 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]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "اگر فکر میکنید این اشتباه است، با پشتیبانی تماس بگیرید."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Если вы считаете, что это ошибка, обратитесь в поддержку."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
14
src/main.tsx
14
src/main.tsx
@@ -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>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppWithNavigator />
|
||||
</QueryClientProvider>
|
||||
<ErrorBoundary level="app">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppWithNavigator />
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,71 +255,84 @@ export default function AdminBanSystem() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
const loadTabData = async (tab: TabType) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const loadTabData = useCallback(
|
||||
async (tab: TabType) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
switch (tab) {
|
||||
case 'dashboard': {
|
||||
const statsData = await banSystemApi.getStats();
|
||||
setStats(statsData);
|
||||
break;
|
||||
}
|
||||
case 'users': {
|
||||
const usersData = await banSystemApi.getUsers({ limit: 50 });
|
||||
setUsers(usersData);
|
||||
break;
|
||||
}
|
||||
case 'punishments': {
|
||||
const punishmentsData = await banSystemApi.getPunishments();
|
||||
setPunishments(punishmentsData);
|
||||
break;
|
||||
}
|
||||
case 'nodes': {
|
||||
const nodesData = await banSystemApi.getNodes();
|
||||
setNodes(nodesData);
|
||||
break;
|
||||
}
|
||||
case 'agents': {
|
||||
const agentsData = await banSystemApi.getAgents();
|
||||
setAgents(agentsData);
|
||||
break;
|
||||
}
|
||||
case 'violations': {
|
||||
const violationsData = await banSystemApi.getTrafficViolations();
|
||||
setViolations(violationsData);
|
||||
break;
|
||||
}
|
||||
case 'settings': {
|
||||
const settingsData = await banSystemApi.getSettings();
|
||||
setSettings(settingsData);
|
||||
break;
|
||||
}
|
||||
case 'traffic': {
|
||||
const trafficData = await banSystemApi.getTraffic();
|
||||
setTraffic(trafficData);
|
||||
break;
|
||||
}
|
||||
case 'reports': {
|
||||
const reportData = await banSystemApi.getReport(reportHours);
|
||||
setReport(reportData);
|
||||
break;
|
||||
}
|
||||
case 'health': {
|
||||
const healthData = await banSystemApi.getHealth();
|
||||
setHealth(healthData);
|
||||
break;
|
||||
switch (tab) {
|
||||
case 'dashboard': {
|
||||
const statsData = await banSystemApi.getStats();
|
||||
setStats(statsData);
|
||||
break;
|
||||
}
|
||||
case 'users': {
|
||||
const usersData = await banSystemApi.getUsers({ limit: 50 });
|
||||
setUsers(usersData);
|
||||
break;
|
||||
}
|
||||
case 'punishments': {
|
||||
const punishmentsData = await banSystemApi.getPunishments();
|
||||
setPunishments(punishmentsData);
|
||||
break;
|
||||
}
|
||||
case 'nodes': {
|
||||
const nodesData = await banSystemApi.getNodes();
|
||||
setNodes(nodesData);
|
||||
break;
|
||||
}
|
||||
case 'agents': {
|
||||
const agentsData = await banSystemApi.getAgents();
|
||||
setAgents(agentsData);
|
||||
break;
|
||||
}
|
||||
case 'violations': {
|
||||
const violationsData = await banSystemApi.getTrafficViolations();
|
||||
setViolations(violationsData);
|
||||
break;
|
||||
}
|
||||
case 'settings': {
|
||||
const settingsData = await banSystemApi.getSettings();
|
||||
setSettings(settingsData);
|
||||
break;
|
||||
}
|
||||
case 'traffic': {
|
||||
const trafficData = await banSystemApi.getTraffic();
|
||||
setTraffic(trafficData);
|
||||
break;
|
||||
}
|
||||
case 'reports': {
|
||||
const reportData = await banSystemApi.getReport(reportHoursRef.current);
|
||||
setReport(reportData);
|
||||
break;
|
||||
}
|
||||
case 'health': {
|
||||
const healthData = await banSystemApi.getHealth();
|
||||
setHealth(healthData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError(t('banSystem.loadError'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
setError(t('banSystem.loadError'));
|
||||
} 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)
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user