Merge pull request #152 from BEDOLAGA-DEV/feat/crypto-links-connection-page

Feat/crypto links connection page
This commit is contained in:
c0mrade
2026-02-05 22:23:50 +03:00
committed by GitHub
33 changed files with 1396 additions and 2596 deletions

View File

@@ -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.
### Фактический результат
*
*
*
-
-
-
### Скрины поведения

View File

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

View File

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

View File

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

View File

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

View File

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

24
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@kastov/cryptohapp": "^1.1.2",
"@lottiefiles/dotlottie-react": "^0.8.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
@@ -33,6 +34,7 @@
"framer-motion": "^12.29.2",
"i18next": "^23.7.0",
"i18next-browser-languagedetector": "^7.2.0",
"jsencrypt": "^3.5.4",
"ogl": "^1.0.11",
"react": "^18.2.0",
"react-dom": "^18.2.0",
@@ -1182,6 +1184,20 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@kastov/cryptohapp": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@kastov/cryptohapp/-/cryptohapp-1.1.2.tgz",
"integrity": "sha512-vwBLWmKJ6g8opVfvzFPZxX3U65ncXawsasqlO5PxVvpejl3MgkpveUkOMoec6gNuGgSSjjxv4ydFH0h3qz3gBw==",
"license": "MIT",
"peerDependencies": {
"jsencrypt": ">=3"
},
"peerDependenciesMeta": {
"jsencrypt": {
"optional": true
}
}
},
"node_modules/@lottiefiles/dotlottie-react": {
"version": "0.8.12",
"resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.8.12.tgz",
@@ -4702,6 +4718,13 @@
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsencrypt": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/jsencrypt/-/jsencrypt-3.5.4.tgz",
"integrity": "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==",
"license": "MIT",
"peer": true
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -5705,7 +5728,6 @@
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"license": "MIT",
"peer": true,
"dependencies": {
"@remix-run/router": "1.23.2",
"react-router": "6.30.3"

View File

@@ -20,6 +20,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@kastov/cryptohapp": "^1.1.2",
"@lottiefiles/dotlottie-react": "^0.8.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
@@ -41,6 +42,7 @@
"framer-motion": "^12.29.2",
"i18next": "^23.7.0",
"i18next-browser-languagedetector": "^7.2.0",
"jsencrypt": "^3.5.4",
"ogl": "^1.0.11",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

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

View File

@@ -26,6 +26,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'));
@@ -262,6 +263,16 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/connection"
element={
<ProtectedRoute>
<LazyPage>
<Connection />
</LazyPage>
</ProtectedRoute>
}
/>
{/* Admin routes */}
<Route

View File

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

View File

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

View File

@@ -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,120 @@ export function ThemeTab() {
queryFn: themeColorsApi.getEnabledThemes,
});
// Local draft state
const [draftColors, setDraftColors] = useState<ThemeColors>(DEFAULT_THEME_COLORS);
const savedColorsRef = useRef<ThemeColors>(DEFAULT_THEME_COLORS);
// 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, draftColors) ||
colorsEqual(savedColorsRef.current, DEFAULT_THEME_COLORS)
) {
setDraftColors(colors);
}
savedColorsRef.current = colors;
}
}, [serverColors]); // eslint-disable-line react-hooks/exhaustive-deps
const hasUnsavedChanges = !colorsEqual(draftColors, savedColorsRef.current);
// 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 to draft
const applyPreset = useCallback(
(colors: Partial<ThemeColors>) => {
setDraftColors((prev) => {
const next = { ...prev, ...colors };
applyThemeColors(next);
queryClient.setQueryData(['theme-colors'], next);
return next;
});
},
[queryClient],
);
// Cancel: revert draft to saved
const handleCancel = useCallback(() => {
const saved = savedColorsRef.current;
setDraftColors(saved);
applyThemeColors(saved);
queryClient.setQueryData(['theme-colors'], saved);
}, [queryClient]);
// 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);
},
});
@@ -130,8 +247,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 +305,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 +318,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 +347,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,33 +376,52 @@ 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>
</>
)}
<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>
)}
</div>

View File

@@ -0,0 +1,438 @@
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 { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock } from './blocks';
import type { BlockRendererProps } from './blocks';
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'];
// 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://');
}
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,
};
// Icons
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>
);
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 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);
const [copied, setCopied] = useState(false);
// --- 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]);
// --- Copy ---
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);
}, []);
// --- Button renderer (passed to block renderers) ---
const renderBlockButtons = useCallback(
(buttons: RemnawaveButtonClient[] | undefined, variant: 'light' | 'subtle') => {
if (!buttons || buttons.length === 0) return null;
const baseClass =
variant === 'light'
? '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'
: '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 ||
selectedApp?.deepLink ||
appConfig.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 (appConfig.hideLink) return null;
const url = btn.resolvedUrl || appConfig.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 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>
);
},
[
selectedApp,
appConfig,
copied,
getSvgHtml,
getLocalizedText,
getBaseTranslation,
handleCopy,
onOpenDeepLink,
t,
],
);
// --- 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 border-dark-700 bg-dark-800 py-2 pr-8 text-sm font-medium text-dark-200 outline-none transition-colors 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
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/40'
: '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}
getLocalizedText={getLocalizedText}
getSvgHtml={getSvgHtml}
renderBlockButtons={renderBlockButtons}
/>
)}
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,43 @@
import { getColorGradientSolid } from '@/utils/colorParser';
import { ThemeIcon } from './ThemeIcon';
import type { BlockRendererProps } from './types';
export function TimelineBlock({
blocks,
isMobile,
getLocalizedText,
getSvgHtml,
renderBlockButtons,
}: BlockRendererProps) {
return (
<div className="space-y-0">
{blocks.map((block, index) => {
const gradientStyle = getColorGradientSolid(block.svgIconColor || 'cyan');
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 bg-dark-700" />}
</div>
{/* Right column: content */}
<div className={`min-w-0 flex-1 ${isLast ? '' : 'pb-6'}`}>
<h3 className="font-semibold text-dark-100">{getLocalizedText(block.title)}</h3>
<p className="mt-1 whitespace-pre-line text-sm leading-relaxed text-dark-400">
{getLocalizedText(block.description)}
</p>
{renderBlockButtons(block.buttons, 'light')}
</div>
</div>
);
})}
</div>
);
}

View File

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

View File

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

View File

@@ -346,7 +346,8 @@
"changeApp": "Change app",
"selectPlatform": "Select platform",
"app": "app",
"apps": "apps"
"apps": "apps",
"tutorial": "Tutorial"
},
"myDevices": "My Devices",
"noDevices": "No connected devices",

View File

@@ -298,7 +298,8 @@
"changeApp": "تغییر برنامه",
"selectPlatform": "انتخاب پلتفرم",
"app": "برنامه",
"apps": "برنامه"
"apps": "برنامه",
"tutorial": "آموزش"
},
"additionalOptions": {
"title": "گزینه‌های اضافی",

View File

@@ -368,7 +368,8 @@
"changeApp": "Сменить приложение",
"selectPlatform": "Выберите платформу",
"app": "приложение",
"apps": "приложений"
"apps": "приложений",
"tutorial": "Инструкция"
},
"myDevices": "Мои устройства",
"noDevices": "Нет подключенных устройств",

View File

@@ -298,7 +298,8 @@
"changeApp": "更换应用",
"selectPlatform": "选择平台",
"app": "个应用",
"apps": "个应用"
"apps": "个应用",
"tutorial": "教程"
},
"additionalOptions": {
"title": "附加选项",

File diff suppressed because it is too large Load Diff

148
src/pages/Connection.tsx Normal file
View File

@@ -0,0 +1,148 @@
import { useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-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 { resolveTemplate, hasTemplates } from '../utils/templateEngine';
import { useAuthStore } from '../store/auth';
import type { AppConfig, LocalizedText } from '../types';
import InstallationGuide from '../components/connection/InstallationGuide';
export default function Connection() {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const { user } = useAuthStore();
const { isTelegramWebApp } = useTelegramWebApp();
const { impact: hapticImpact } = useHaptic();
const hapticRef = useRef(hapticImpact);
hapticRef.current = hapticImpact;
const {
data: appConfig,
isLoading,
error,
} = useQuery<AppConfig>({
queryKey: ['appConfig'],
queryFn: () => subscriptionApi.getAppConfig(),
});
const handleGoBack = useCallback(() => {
navigate(-1);
}, [navigate]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
handleGoBack();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleGoBack]);
const handleBackButton = useCallback(() => {
hapticRef.current('light');
handleGoBack();
}, [handleGoBack]);
useBackButton(handleBackButton);
const resolveUrl = useCallback(
(url: string): string => {
if (!hasTemplates(url) || !appConfig?.subscriptionUrl) return url;
return resolveTemplate(url, {
subscriptionUrl: appConfig.subscriptionUrl,
username: user?.username ?? undefined,
});
},
[appConfig?.subscriptionUrl, user?.username],
);
const openDeepLink = useCallback(
(deepLink: string) => {
let resolved = deepLink;
if (hasTemplates(resolved)) {
resolved = resolveUrl(resolved);
}
const finalUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(resolved)}&lang=${i18n.language || 'en'}`;
if (isTelegramWebApp) {
try {
sdkOpenLink(finalUrl, { tryInstantView: false });
return;
} catch {
// SDK not available, fallback
}
}
window.location.href = finalUrl;
},
[isTelegramWebApp, i18n.language, resolveUrl],
);
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 getBaseTranslation = (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);
};
// Loading
if (isLoading) {
return (
<div className="flex flex-1 items-center justify-center py-20">
<div className="h-10 w-10 animate-spin rounded-full border-[3px] border-accent-500/30 border-t-accent-500" />
</div>
);
}
// Error
if (error || !appConfig) {
return (
<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={handleGoBack} className="btn-primary px-6 py-2">
{getBaseTranslation('close', 'common.close')}
</button>
</div>
);
}
// No subscription
if (!appConfig.hasSubscription) {
return (
<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={handleGoBack} className="btn-primary px-6 py-2">
{getBaseTranslation('close', 'common.close')}
</button>
</div>
);
}
return (
<InstallationGuide
appConfig={appConfig}
onOpenDeepLink={openDeepLink}
isTelegramWebApp={isTelegramWebApp}
onGoBack={handleGoBack}
/>
);
}

View File

@@ -1,13 +1,12 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/auth';
import { subscriptionApi } from '../api/subscription';
import { referralApi } from '../api/referral';
import { balanceApi } from '../api/balance';
import { wheelApi } from '../api/wheel';
import ConnectionModal from '../components/ConnectionModal';
import Onboarding, { useOnboarding } from '../components/Onboarding';
import PromoOffersSection from '../components/PromoOffersSection';
import { useCurrency } from '../hooks/useCurrency';
@@ -49,9 +48,9 @@ export default function Dashboard() {
const { t } = useTranslation();
const { user, refreshUser } = useAuthStore();
const queryClient = useQueryClient();
const navigate = useNavigate();
const { formatAmount, currencySymbol, formatPositive } = useCurrency();
const [trialError, setTrialError] = useState<string | null>(null);
const [showConnectionModal, setShowConnectionModal] = useState(false);
const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding();
const [showOnboarding, setShowOnboarding] = useState(false);
@@ -410,7 +409,7 @@ export default function Dashboard() {
</Link>
{subscription.subscription_url && (
<button
onClick={() => setShowConnectionModal(true)}
onClick={() => navigate('/connection')}
className="btn-secondary py-2.5 text-sm"
data-onboarding="connect-devices"
>
@@ -635,9 +634,6 @@ export default function Dashboard() {
</Link>
)}
{/* Connection Modal */}
{showConnectionModal && <ConnectionModal onClose={() => setShowConnectionModal(false)} />}
{/* Onboarding Tutorial */}
{showOnboarding && (
<Onboarding

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { AxiosError } from 'axios';
import { subscriptionApi } from '../api/subscription';
import { promoApi } from '../api/promo';
@@ -12,7 +12,6 @@ import type {
TariffPeriod,
ClassicPurchaseOptions,
} from '../types';
import ConnectionModal from '../components/ConnectionModal';
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
import { useCurrency } from '../hooks/useCurrency';
import { useCloseOnSuccessNotification } from '../store/successNotification';
@@ -83,9 +82,9 @@ export default function Subscription() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const location = useLocation();
const navigate = useNavigate();
const { formatAmount, currencySymbol } = useCurrency();
const [copied, setCopied] = useState(false);
const [showConnectionModal, setShowConnectionModal] = useState(false);
// Helper to format price from kopeks
const formatPrice = (kopeks: number) => `${formatAmount(kopeks / 100)} ${currencySymbol}`;
@@ -309,7 +308,6 @@ export default function Subscription() {
// Auto-close all modals/forms when success notification appears (e.g., subscription purchased via WebSocket)
const handleCloseAllModals = useCallback(() => {
setShowConnectionModal(false);
setShowPurchaseForm(false);
setShowTariffPurchase(false);
setShowDeviceTopup(false);
@@ -737,7 +735,7 @@ export default function Subscription() {
{/* Get Config Button */}
<button
onClick={() => setShowConnectionModal(true)}
onClick={() => navigate('/connection')}
className="btn-primary mb-3 flex w-full items-center justify-center gap-2 py-3"
>
<svg
@@ -1135,7 +1133,7 @@ export default function Subscription() {
{!showDeviceTopup ? (
<button
onClick={() => setShowDeviceTopup(true)}
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/30 p-4 text-left transition-colors hover:border-dark-600"
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 p-4 text-left transition-colors hover:border-dark-600"
>
<div className="flex items-center justify-between">
<div>
@@ -1160,7 +1158,7 @@ export default function Subscription() {
</div>
</button>
) : (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-5">
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-dark-100">{t('subscription.buyDevices')}</h3>
<button
@@ -1327,7 +1325,7 @@ export default function Subscription() {
{!showDeviceReduction ? (
<button
onClick={() => setShowDeviceReduction(true)}
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/30 p-4 text-left transition-colors hover:border-dark-600"
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 p-4 text-left transition-colors hover:border-dark-600"
>
<div className="flex items-center justify-between">
<div>
@@ -1350,7 +1348,7 @@ export default function Subscription() {
</div>
</button>
) : (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-5">
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-dark-100">
{t('subscription.additionalOptions.reduceDevicesTitle')}
@@ -1496,7 +1494,7 @@ export default function Subscription() {
{!showTrafficTopup ? (
<button
onClick={() => setShowTrafficTopup(true)}
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/30 p-4 text-left transition-colors hover:border-dark-600"
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 p-4 text-left transition-colors hover:border-dark-600"
>
<div className="flex items-center justify-between">
<div>
@@ -1522,7 +1520,7 @@ export default function Subscription() {
</div>
</button>
) : (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-5">
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-dark-100">
{t('subscription.additionalOptions.buyTrafficTitle')}
@@ -1556,7 +1554,7 @@ export default function Subscription() {
className={`rounded-xl border p-4 text-center transition-all ${
selectedTrafficPackage === pkg.gb
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600'
: 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
}`}
>
<div className="text-lg font-semibold text-dark-100">
@@ -1656,7 +1654,7 @@ export default function Subscription() {
{!showServerManagement ? (
<button
onClick={() => setShowServerManagement(true)}
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/30 p-4 text-left transition-colors hover:border-dark-600"
className="w-full rounded-xl border border-dark-700/50 bg-dark-800/50 p-4 text-left transition-colors hover:border-dark-600"
>
<div className="flex items-center justify-between">
<div>
@@ -1679,7 +1677,7 @@ export default function Subscription() {
</div>
</button>
) : (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-5">
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="font-medium text-dark-100">
{t('subscription.additionalOptions.manageServersTitle')}
@@ -1743,7 +1741,7 @@ export default function Subscription() {
: 'border-accent-500 bg-accent-500/10'
: willBeRemoved
? 'border-error-500/50 bg-error-500/5'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600'
: 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
} ${!country.is_available && !isCurrentlyConnected ? 'cursor-not-allowed opacity-50' : ''}`}
>
<div className="flex items-center gap-3">
@@ -1956,7 +1954,7 @@ export default function Subscription() {
{devicesData.devices.map((device) => (
<div
key={device.hwid}
className="flex items-center justify-between rounded-xl border border-dark-700/50 bg-dark-800/30 p-4"
className="flex items-center justify-between rounded-xl border border-dark-700/50 bg-dark-800/50 p-4"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-dark-700">
@@ -2577,7 +2575,7 @@ export default function Subscription() {
</div>
{/* Tariff Info */}
<div className="rounded-xl bg-dark-800/30 p-4">
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="flex flex-wrap gap-4 text-sm">
<div>
<span className="text-dark-500">{t('subscription.traffic')}:</span>
@@ -2741,7 +2739,7 @@ export default function Subscription() {
className={`relative rounded-xl border p-4 text-left transition-all ${
selectedTariffPeriod?.days === period.days && !useCustomDays
? 'border-accent-500 bg-accent-500/10'
: 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600'
: 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
}`}
>
{displayDiscount && displayDiscount > 0 && (
@@ -2778,7 +2776,7 @@ export default function Subscription() {
{/* Custom days option */}
{selectedTariff.custom_days_enabled &&
(selectedTariff.price_per_day_kopeks ?? 0) > 0 && (
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="font-medium text-dark-200">
{t('subscription.customDays.title')}
@@ -2878,7 +2876,7 @@ export default function Subscription() {
<div className="mb-3 text-sm text-dark-400">
{t('subscription.customTraffic.label')}
</div>
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
<div className="mb-3 flex items-center justify-between">
<span className="font-medium text-dark-200">
{t('subscription.customTraffic.selectVolume')}
@@ -3349,7 +3347,7 @@ export default function Subscription() {
selectedServers.includes(server.uuid)
? 'border-accent-500 bg-accent-500/10'
: server.is_available
? 'border-dark-700/50 bg-dark-800/30 hover:border-dark-600'
? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
: 'cursor-not-allowed border-dark-800/30 bg-dark-900/30 opacity-50'
}`}
>
@@ -3592,9 +3590,6 @@ export default function Subscription() {
)}
</div>
)}
{/* Connection Modal */}
{showConnectionModal && <ConnectionModal onClose={() => setShowConnectionModal(false)} />}
</div>
);
}

View File

@@ -486,32 +486,39 @@ export interface LocalizedText {
[key: string]: string;
}
export interface AppButton {
id?: string; // Unique identifier for React key (client-side only)
buttonLink: string;
buttonText: LocalizedText;
// RemnaWave format types
export interface RemnawaveButtonClient {
url?: string;
link?: string;
text: LocalizedText;
type?: 'external' | 'subscriptionLink' | 'copyButton';
svgIconKey?: string;
resolvedUrl?: string;
}
export interface AppStep {
export interface RemnawaveBlockClient {
title: LocalizedText;
description: LocalizedText;
buttons?: AppButton[];
title?: LocalizedText;
buttons?: RemnawaveButtonClient[];
svgIconKey?: string;
svgIconColor?: string;
}
export interface AppInfo {
id: string;
export interface RemnawaveAppClient {
name: string;
isFeatured: boolean;
featured?: boolean;
deepLink?: string | null;
installationStep?: AppStep;
addSubscriptionStep?: AppStep;
connectAndUseStep?: AppStep;
additionalBeforeAddSubscriptionStep?: AppStep;
additionalAfterAddSubscriptionStep?: AppStep;
svgIconKey?: string;
blocks: RemnawaveBlockClient[];
}
export interface RemnawavePlatformData {
svgIconKey?: string;
displayName?: LocalizedText;
apps: RemnawaveAppClient[];
}
export interface AppConfig {
platforms: Record<string, AppInfo[]>;
platformNames: Record<string, LocalizedText>;
hasSubscription: boolean;
subscriptionUrl: string | null;
@@ -521,6 +528,17 @@ export interface AppConfig {
logoUrl?: string;
supportUrl?: string;
};
// RemnaWave
isRemnawave?: boolean;
svgLibrary?: Record<string, string | { svgString: string }>;
baseTranslations?: Record<string, LocalizedText>;
baseSettings?: { isShowTutorialButton: boolean; tutorialUrl: string };
uiConfig?: {
installationGuidesBlockType?: 'cards' | 'timeline' | 'accordion' | 'minimal';
};
platforms: Record<string, RemnawavePlatformData>;
}
// Pending payment types

52
src/utils/colorParser.ts Normal file
View File

@@ -0,0 +1,52 @@
const COLORS: Record<string, [number, number, number]> = {
cyan: [34, 211, 238],
teal: [32, 201, 151],
green: [64, 192, 87],
lime: [130, 201, 30],
yellow: [250, 176, 5],
orange: [253, 126, 20],
red: [250, 82, 82],
pink: [230, 73, 128],
grape: [190, 75, 219],
violet: [151, 117, 250],
indigo: [92, 124, 250],
blue: [34, 139, 230],
gray: [134, 142, 150],
dark: [55, 58, 64],
};
const DEFAULT_COLOR = COLORS.cyan;
const hexToRgb = (hex: string): [number, number, number] | null => {
const match = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return match ? [parseInt(match[1], 16), parseInt(match[2], 16), parseInt(match[3], 16)] : null;
};
const getRgb = (color: string): [number, number, number] =>
COLORS[color] ?? hexToRgb(color) ?? DEFAULT_COLOR;
export interface ColorGradientStyle {
background: string;
border: string;
boxShadow?: string;
}
export const getColorGradient = (color: string): ColorGradientStyle => {
const [r, g, b] = getRgb(color);
return {
background: `linear-gradient(135deg, rgba(${r},${g},${b},0.15) 0%, rgba(${r},${g},${b},0.08) 100%)`,
border: `1px solid rgba(${r},${g},${b},0.3)`,
};
};
export const getColorGradientSolid = (color: string): ColorGradientStyle => {
const [r, g, b] = getRgb(color);
const dark1 = [22 + r * 0.08, 27 + g * 0.08, 35 + b * 0.08].map(Math.floor);
const dark2 = [20 + r * 0.05, 24 + g * 0.05, 30 + b * 0.05].map(Math.floor);
return {
background: `linear-gradient(135deg, rgb(${dark1}) 0%, rgb(${dark2}) 100%)`,
border: `1px solid rgba(${r},${g},${b},0.4)`,
boxShadow: `inset 0 0 20px rgba(${r},${g},${b},0.15)`,
};
};

View File

@@ -0,0 +1,32 @@
import { createHappCryptoLink } from '@kastov/cryptohapp';
const TEMPLATE_RE = /\{\{[A-Z0-9_]+\}\}/;
export function hasTemplates(url: string): boolean {
return TEMPLATE_RE.test(url);
}
interface ResolveContext {
subscriptionUrl: string;
username?: string;
}
export function resolveTemplate(template: string, ctx: ResolveContext): string {
let result = template;
result = result.replace(/\{\{SUBSCRIPTION_LINK\}\}/g, ctx.subscriptionUrl);
if (ctx.username) {
result = result.replace(/\{\{USERNAME\}\}/g, ctx.username);
}
result = result.replace(/\{\{HAPP_CRYPT3_LINK\}\}/g, () => {
return createHappCryptoLink(ctx.subscriptionUrl, 'v3', true) ?? ctx.subscriptionUrl;
});
result = result.replace(/\{\{HAPP_CRYPT4_LINK\}\}/g, () => {
return createHappCryptoLink(ctx.subscriptionUrl, 'v4', true) ?? ctx.subscriptionUrl;
});
return result;
}

View File

@@ -4,17 +4,14 @@
const withOpacity = (variableName, fallback) => {
return ({ opacityValue }) => {
if (opacityValue !== undefined) {
return `rgba(var(${variableName}), ${opacityValue})`
return `rgba(var(${variableName}), ${opacityValue})`;
}
return `rgb(var(${variableName}))`
}
}
return `rgb(var(${variableName}))`;
};
};
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {
@@ -106,17 +103,25 @@ export default {
},
},
fontFamily: {
sans: ['Manrope', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
sans: [
'Manrope',
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'Roboto',
'sans-serif',
],
},
borderRadius: {
'bento': '24px',
bento: '24px',
'4xl': '32px',
// Linear design tokens
'linear': '8px',
linear: '8px',
'linear-lg': '12px',
},
spacing: {
'bento': '16px',
bento: '16px',
'bento-lg': '24px',
// Linear design tokens
'linear-xs': '4px',
@@ -126,19 +131,19 @@ export default {
'linear-xl': '32px',
},
backdropBlur: {
'linear': '12px',
linear: '12px',
},
fontSize: {
'2xs': ['0.625rem', { lineHeight: '0.875rem' }],
},
boxShadow: {
'glow': '0 0 20px rgba(var(--color-accent-500), 0.15)',
glow: '0 0 20px rgba(var(--color-accent-500), 0.15)',
'glow-lg': '0 0 40px rgba(var(--color-accent-500), 0.2)',
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2)',
'card': '0 4px 24px -4px rgba(0, 0, 0, 0.4)',
soft: '0 2px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.2)',
card: '0 4px 24px -4px rgba(0, 0, 0, 0.4)',
// Linear design tokens
'linear-sm': '0 1px 2px rgba(0, 0, 0, 0.05)',
'linear': '0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06)',
linear: '0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06)',
'linear-lg': '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
'linear-glow': '0 0 0 1px rgba(var(--color-accent-500), 0.3)',
},
@@ -156,9 +161,9 @@ export default {
'scale-in': 'scaleIn 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
'scale-in-bounce': 'scaleInBounce 0.5s cubic-bezier(0.34, 1.56, 0.64, 1)',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'float': 'float 3s ease-in-out infinite',
float: 'float 3s ease-in-out infinite',
'glow-pulse': 'glowPulse 2s ease-in-out infinite',
'spotlight': 'spotlight 2s ease-in-out infinite',
spotlight: 'spotlight 2s ease-in-out infinite',
},
keyframes: {
fadeIn: {
@@ -199,14 +204,20 @@ export default {
'50%': { boxShadow: '0 0 40px rgba(var(--color-accent-500), 0.6)' },
},
spotlight: {
'0%, 100%': { boxShadow: '0 0 0 4px rgba(var(--color-accent-500), 0.4), 0 0 20px rgba(var(--color-accent-500), 0.3)' },
'50%': { boxShadow: '0 0 0 8px rgba(var(--color-accent-500), 0.2), 0 0 40px rgba(var(--color-accent-500), 0.5)' },
'0%, 100%': {
boxShadow:
'0 0 0 4px rgba(var(--color-accent-500), 0.4), 0 0 20px rgba(var(--color-accent-500), 0.3)',
},
'50%': {
boxShadow:
'0 0 0 8px rgba(var(--color-accent-500), 0.2), 0 0 40px rgba(var(--color-accent-500), 0.5)',
},
},
},
transitionTimingFunction: {
'smooth': 'cubic-bezier(0.4, 0, 0.2, 1)',
smooth: 'cubic-bezier(0.4, 0, 0.2, 1)',
},
},
},
plugins: [],
}
};