diff --git a/.github/ISSUE_TEMPLATE/bug-bedolage.md b/.github/ISSUE_TEMPLATE/bug-bedolage.md index fd68c55..67474be 100644 --- a/.github/ISSUE_TEMPLATE/bug-bedolage.md +++ b/.github/ISSUE_TEMPLATE/bug-bedolage.md @@ -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. ### Фактический результат -* -* -* +- +- +- ### Скрины поведения diff --git a/.github/ISSUE_TEMPLATE/feat-bedolage.md b/.github/ISSUE_TEMPLATE/feat-bedolage.md index 37cc450..fe6c6e2 100644 --- a/.github/ISSUE_TEMPLATE/feat-bedolage.md +++ b/.github/ISSUE_TEMPLATE/feat-bedolage.md @@ -1,10 +1,9 @@ --- name: Feat Bedolage about: Создать запрос на фичу «Бедолаге» -title: "[FEAT] Заголовок" +title: '[FEAT] Заголовок' labels: '' assignees: '' - --- ## Описание / Конфигурация diff --git a/CHANGELOG.md b/CHANGELOG.md index def85bb..702007f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,102 +2,96 @@ ## [1.7.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.6.2...v1.7.0) (2026-02-04) - ### Features -* add Stars payment confirmation and admin validation ([e6f8ae6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e6f8ae6ab09c431d5322c851165f30469678ed72)) -* replace payment modals with page-based navigation ([576893f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/576893f5c6b67c19bee0cd562cd0430a88350619)) - +- add Stars payment confirmation and admin validation ([e6f8ae6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e6f8ae6ab09c431d5322c851165f30469678ed72)) +- replace payment modals with page-based navigation ([576893f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/576893f5c6b67c19bee0cd562cd0430a88350619)) ### Bug Fixes -* dim accent color for background blobs ([bb32cd8](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bb32cd8757b116728c0e7357fc40bcb842e7a476)) -* inline Stars confirmation and unified payment type display ([8068f84](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8068f847247307aa3adae1f6965987882be8a785)) -* prevent payment type reset after wheel spin ([4499c9a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4499c9ad57dcdcd3126c8d4261bc9f32accd21d7)) -* unify wheel Stars payment across desktop and mobile ([02640d1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/02640d1c38dde00d431058a399adcc85fd9bcaac)) -* update Aurora colors reactively without recreating WebGL context ([59a251c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/59a251cb8c706b3029990e58fc6003ce620f80d3)) +- dim accent color for background blobs ([bb32cd8](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bb32cd8757b116728c0e7357fc40bcb842e7a476)) +- inline Stars confirmation and unified payment type display ([8068f84](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8068f847247307aa3adae1f6965987882be8a785)) +- prevent payment type reset after wheel spin ([4499c9a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4499c9ad57dcdcd3126c8d4261bc9f32accd21d7)) +- unify wheel Stars payment across desktop and mobile ([02640d1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/02640d1c38dde00d431058a399adcc85fd9bcaac)) +- update Aurora colors reactively without recreating WebGL context ([59a251c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/59a251cb8c706b3029990e58fc6003ce620f80d3)) ## [1.6.2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.6.1...v1.6.2) (2026-02-04) - ### Bug Fixes -* add theme toggle to desktop header and sync theme across components ([bf00d37](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bf00d37b4af799130e3dd8cd2c083ec933833281)) -* Aurora animation ignoring light theme background color ([c1dc019](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c1dc019c8b2819e13da42ad1c2648740a09279d1)) -* replace individual light theme overrides with CSS variable swap ([9ac00c9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/9ac00c94a6805577d6ac71e83fae5032217a31c9)) -* use dynamic champagne variables for light theme palette swap ([ecd912b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ecd912b16a63b692b45361cfa53e7cacb0cb3e4f)) -* use theme surface and background colors for Aurora animation ([a91e055](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a91e0555979540f50ca1afef5ee8c91b162c4a7f)) +- add theme toggle to desktop header and sync theme across components ([bf00d37](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bf00d37b4af799130e3dd8cd2c083ec933833281)) +- Aurora animation ignoring light theme background color ([c1dc019](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c1dc019c8b2819e13da42ad1c2648740a09279d1)) +- replace individual light theme overrides with CSS variable swap ([9ac00c9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/9ac00c94a6805577d6ac71e83fae5032217a31c9)) +- use dynamic champagne variables for light theme palette swap ([ecd912b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ecd912b16a63b692b45361cfa53e7cacb0cb3e4f)) +- use theme surface and background colors for Aurora animation ([a91e055](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a91e0555979540f50ca1afef5ee8c91b162c4a7f)) ## [1.6.1](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.6.0...v1.6.1) (2026-02-04) - ### Bug Fixes -* add fallback recovery for Telegram popup callback not firing ([7ac7db4](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7ac7db4ddb2950216334be01db37f185594bea6e)) -* add HMR guard to prevent ConcurrentCallError on SDK double-init ([bcbda17](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bcbda17220357815ee2df269e293db0fecee7bd3)) -* get fresh Telegram WebApp reference on each popup call ([792fb1e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/792fb1ed8a1cc4d4a5350556308e00e8cad5313a)) -* prevent duplicate Telegram popup opening ([71647eb](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/71647ebc8795fc57f958d4fdedfb2f9c0b23837e)) -* prevent popup cascade when Telegram callback doesn't fire ([2d00a5c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2d00a5c21fff339f229d8fe2001a898d15722cdd)) -* resolve SDK v3 mount errors, back button and fullscreen not working ([61e3910](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/61e3910981e401fbc0b968615307e5101f6f96e9)) -* use direct dialog.popup call instead of useDestructiveConfirm ([ef77276](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ef77276246fdb60255f625289542584b83c93fcc)) +- add fallback recovery for Telegram popup callback not firing ([7ac7db4](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7ac7db4ddb2950216334be01db37f185594bea6e)) +- add HMR guard to prevent ConcurrentCallError on SDK double-init ([bcbda17](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bcbda17220357815ee2df269e293db0fecee7bd3)) +- get fresh Telegram WebApp reference on each popup call ([792fb1e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/792fb1ed8a1cc4d4a5350556308e00e8cad5313a)) +- prevent duplicate Telegram popup opening ([71647eb](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/71647ebc8795fc57f958d4fdedfb2f9c0b23837e)) +- prevent popup cascade when Telegram callback doesn't fire ([2d00a5c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2d00a5c21fff339f229d8fe2001a898d15722cdd)) +- resolve SDK v3 mount errors, back button and fullscreen not working ([61e3910](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/61e3910981e401fbc0b968615307e5101f6f96e9)) +- use direct dialog.popup call instead of useDestructiveConfirm ([ef77276](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ef77276246fdb60255f625289542584b83c93fcc)) ## [1.6.0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/compare/v1.5.0...v1.6.0) (2026-02-04) - ### Features -* add animated MovingGradient background ([24781f3](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/24781f32ec0b7889ddb4a88d994d91b6d8593dec)) -* add autocomplete to settings search ([e5096d5](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e5096d571ff0b9ea3fb9c08c1cbece46a1ece656)) -* add locales for user search in promo offer sending ([0c9d092](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/0c9d09280c600e03c8cd8b7f51c8eb27c664c22c)) -* add Telegram/Email channel selection to broadcast create page ([0773afd](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/0773afdf6e37f0b8da679a176048764110795919)) -* add useNotify hook for unified notifications ([6f4d1ef](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/6f4d1ef08587a671a3060c304bc8603df5d9a17e)) -* add user search autocomplete for promo offer sending ([fc92267](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/fc922671d2fe2de7e4961205158c8e7e3404020a)) -* extract promocodes and promo groups into separate pages ([a96ddde](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a96ddde314d938c2ee2e7c8fe7eaab84007060df)) -* improve tariff builder UI ([e19767a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e19767af82ed84c80f1e5cb0e9535962d360fd54)) -* Linear-style UI redesign with improved mobile experience ([b953ee0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/b953ee0b8c79c6340eca0467d65fee22d362875e)) -* migrate to [@tma](https://github.com/tma).js/sdk-react for Telegram Mini App ([edb5be0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/edb5be09ae372b6ee2985484518bdf76d87b89e4)) -* move campaign statistics to dedicated page ([1027deb](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/1027deb134d58f72f40b2f18878e8700683a4c86)) -* move ticket settings to dedicated page ([ead4606](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ead4606bb59e59c50445c7b7198abf42c54e1326)) -* redesign fortune wheel UI and add to mobile nav ([7e2802c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7e2802c5b5ee4bae1cf8d07009056e9c66688197)) -* redesign fortune wheel with improved UX ([494285b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/494285bcbf99d8f74873dcf571b0a780e968100b)) -* replace broadcast creation modal with dedicated page ([175516e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/175516ec9bc85575483fa7223838e5f52e4cfe7b)) -* replace campaign creation modal with dedicated page ([bce4d94](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bce4d94229c81d67a7621faccf9a69bd6b61d5e9)) -* replace MovingGradient with Aurora WebGL background ([cffef41](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/cffef41f634f940da07645461420f32554e6da9d)) -* replace tariff creation modal with dedicated page ([dc17695](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/dc1769520612286bc2da4bd25c23a60ab38792f3)) -* scroll to tariffs section when clicking discount badge ([3613294](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/3613294a7869e6866d3f3d5d2dccde4102e05b9f)) - +- add animated MovingGradient background ([24781f3](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/24781f32ec0b7889ddb4a88d994d91b6d8593dec)) +- add autocomplete to settings search ([e5096d5](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e5096d571ff0b9ea3fb9c08c1cbece46a1ece656)) +- add locales for user search in promo offer sending ([0c9d092](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/0c9d09280c600e03c8cd8b7f51c8eb27c664c22c)) +- add Telegram/Email channel selection to broadcast create page ([0773afd](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/0773afdf6e37f0b8da679a176048764110795919)) +- add useNotify hook for unified notifications ([6f4d1ef](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/6f4d1ef08587a671a3060c304bc8603df5d9a17e)) +- add user search autocomplete for promo offer sending ([fc92267](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/fc922671d2fe2de7e4961205158c8e7e3404020a)) +- extract promocodes and promo groups into separate pages ([a96ddde](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/a96ddde314d938c2ee2e7c8fe7eaab84007060df)) +- improve tariff builder UI ([e19767a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e19767af82ed84c80f1e5cb0e9535962d360fd54)) +- Linear-style UI redesign with improved mobile experience ([b953ee0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/b953ee0b8c79c6340eca0467d65fee22d362875e)) +- migrate to [@tma](https://github.com/tma).js/sdk-react for Telegram Mini App ([edb5be0](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/edb5be09ae372b6ee2985484518bdf76d87b89e4)) +- move campaign statistics to dedicated page ([1027deb](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/1027deb134d58f72f40b2f18878e8700683a4c86)) +- move ticket settings to dedicated page ([ead4606](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ead4606bb59e59c50445c7b7198abf42c54e1326)) +- redesign fortune wheel UI and add to mobile nav ([7e2802c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7e2802c5b5ee4bae1cf8d07009056e9c66688197)) +- redesign fortune wheel with improved UX ([494285b](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/494285bcbf99d8f74873dcf571b0a780e968100b)) +- replace broadcast creation modal with dedicated page ([175516e](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/175516ec9bc85575483fa7223838e5f52e4cfe7b)) +- replace campaign creation modal with dedicated page ([bce4d94](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/bce4d94229c81d67a7621faccf9a69bd6b61d5e9)) +- replace MovingGradient with Aurora WebGL background ([cffef41](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/cffef41f634f940da07645461420f32554e6da9d)) +- replace tariff creation modal with dedicated page ([dc17695](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/dc1769520612286bc2da4bd25c23a60ab38792f3)) +- scroll to tariffs section when clicking discount badge ([3613294](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/3613294a7869e6866d3f3d5d2dccde4102e05b9f)) ### Bug Fixes -* add full i18n support for RemnaWave section and improve sync UI ([ed86dfa](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ed86dfa8bd0c680f842bc17ef7663ee1d266ee11)) -* add missing i18n keys for broadcast detail page ([c60a242](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c60a242f1da4b03d44d56485d83313755a5a0c8e)) -* add placeholders to all tariff form number inputs ([8cd95b8](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8cd95b84fb703104d436d4ce417d3ab57aa66f9f)) -* add Russian translation for device limit reduction reason ([e884860](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e884860ab86b6cbcdb9db2858f47bd3813a18740)) -* add Telegram header padding for Android fullscreen mode ([093c9f2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/093c9f28935133cd300bf4c481d5c798011ecbfe)) -* AdminTariffCreate back button and daily tariff colors ([d623cd4](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d623cd41e9e9e15733c68b9640f0296f37043468)) -* allow clearing number inputs and add validation ([47e28ee](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/47e28ee78fd417db86ad4a026f5983f68f0e1c76)) -* Aurora uses theme colors from API with blur overlay ([55ae55f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/55ae55f4af1050207ceac6db992f6bf66ce4b77b)) -* disable Telegram swipe-to-close globally to prevent accidental app closures ([9b0be28](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/9b0be280d292179fb6e9f219c21d18ce1ae7e5fc)) -* disable Telegram vertical swipes during drag operations ([8deca2f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8deca2fa5bf1da2ba76191616d4a1638d1d79f9d)) -* handle unmounted SDK components gracefully ([baa57b9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/baa57b907ecb3ada3623eafcffbc070a56e1915c)) -* improve admin user detail tabs scroll and sync buttons design ([45dac03](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/45dac039f9e77f55fefe6ea1e761edd037bd13d8)) -* improve header layout for mobile - stack button below title ([643d4fd](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/643d4fd3af891fc9a9e27501dae715f91242f200)) -* improve mobile layout for bandwidth and add pluralization support ([e9af285](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e9af285dadeeb0ce951940be46e07561f61844d9)) -* improve tariff delete button visibility ([5b30f24](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5b30f24e7e3bd672464a0314433d8649267e7828)) -* improve Toast visibility and allow tariff deletion with subscriptions ([36cc01c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/36cc01ca7e2489d603d96673f93237648832223e)) -* make Aurora colors vibrant and increase speed ([851e6a3](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/851e6a353bbf5c48c7cdf36ba1d9fd821c775aff)) -* promo offer button mobile layout ([1d4a99c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/1d4a99c47432128402d213a3be3cbd9f316e6171)) -* remove dark backgrounds causing black rectangles ([2926a5a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2926a5a89c522b2133b9f56eef950031c0f7f2c1)) -* remove page transition animations to prevent flashing ([dda8323](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/dda8323b452bd3d3054d5bbdd607ce5c7a27a6a4)) -* remove quick actions section and optimize build chunks ([de613d9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/de613d909d64b4e1853032447df671939ddad9b2)) -* remove small discount badge and improve large badge UX ([822b9a6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/822b9a6265e7c9b14c4ef630e80ee81ef9597496)) -* restore page animations, improve checkbox visibility ([5ad5e8d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5ad5e8d3657b8074305fd1c8b94cebd5c6cb4af2)) -* revert to native Telegram WebApp API, remove SDK usage ([6f8bc4f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/6f8bc4fca592f074d4705069a48bbaef3e5a105d)) -* scroll to start of tariffs section and wait for data to load ([c815ac2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c815ac29ea281056908f39dfb6a390b3b201377b)) -* UI improvements - reduce Android header, hide mobile scrollbar, disable animations in Telegram, consistent menu overlay ([768b340](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/768b340c35c5f145940bb9966e2e6713039be32f)) -* unify card styles across the project ([4a25d8d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4a25d8df03b26f25800f882e46223bab64873b73)) -* use native Telegram popup for email preview restriction ([7aeb47f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7aeb47f583056d651b90db4ed7cad2d2a2aef3b1)) -* use pill-style tabs in admin user detail page ([09584fc](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/09584fc901d778453fbda437d643f4bd9c4321e3)) -* use RemnaWave icon in admin panel menu ([4034b4d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4034b4db3932cab273636bb54bf796aff6de712c)) -* use single shared WebSocket connection and optimize build chunks ([f6854c6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f6854c6c3aef3ff46d7839dfe94d2ec6bf4d7d64)) -* wrap all SDK isSupported() calls in try-catch ([e5ea09d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e5ea09dd3a13ec47ca65de93c6f5858c1fadb135)) +- add full i18n support for RemnaWave section and improve sync UI ([ed86dfa](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/ed86dfa8bd0c680f842bc17ef7663ee1d266ee11)) +- add missing i18n keys for broadcast detail page ([c60a242](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c60a242f1da4b03d44d56485d83313755a5a0c8e)) +- add placeholders to all tariff form number inputs ([8cd95b8](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8cd95b84fb703104d436d4ce417d3ab57aa66f9f)) +- add Russian translation for device limit reduction reason ([e884860](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e884860ab86b6cbcdb9db2858f47bd3813a18740)) +- add Telegram header padding for Android fullscreen mode ([093c9f2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/093c9f28935133cd300bf4c481d5c798011ecbfe)) +- AdminTariffCreate back button and daily tariff colors ([d623cd4](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/d623cd41e9e9e15733c68b9640f0296f37043468)) +- allow clearing number inputs and add validation ([47e28ee](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/47e28ee78fd417db86ad4a026f5983f68f0e1c76)) +- Aurora uses theme colors from API with blur overlay ([55ae55f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/55ae55f4af1050207ceac6db992f6bf66ce4b77b)) +- disable Telegram swipe-to-close globally to prevent accidental app closures ([9b0be28](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/9b0be280d292179fb6e9f219c21d18ce1ae7e5fc)) +- disable Telegram vertical swipes during drag operations ([8deca2f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/8deca2fa5bf1da2ba76191616d4a1638d1d79f9d)) +- handle unmounted SDK components gracefully ([baa57b9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/baa57b907ecb3ada3623eafcffbc070a56e1915c)) +- improve admin user detail tabs scroll and sync buttons design ([45dac03](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/45dac039f9e77f55fefe6ea1e761edd037bd13d8)) +- improve header layout for mobile - stack button below title ([643d4fd](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/643d4fd3af891fc9a9e27501dae715f91242f200)) +- improve mobile layout for bandwidth and add pluralization support ([e9af285](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e9af285dadeeb0ce951940be46e07561f61844d9)) +- improve tariff delete button visibility ([5b30f24](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5b30f24e7e3bd672464a0314433d8649267e7828)) +- improve Toast visibility and allow tariff deletion with subscriptions ([36cc01c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/36cc01ca7e2489d603d96673f93237648832223e)) +- make Aurora colors vibrant and increase speed ([851e6a3](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/851e6a353bbf5c48c7cdf36ba1d9fd821c775aff)) +- promo offer button mobile layout ([1d4a99c](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/1d4a99c47432128402d213a3be3cbd9f316e6171)) +- remove dark backgrounds causing black rectangles ([2926a5a](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/2926a5a89c522b2133b9f56eef950031c0f7f2c1)) +- remove page transition animations to prevent flashing ([dda8323](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/dda8323b452bd3d3054d5bbdd607ce5c7a27a6a4)) +- remove quick actions section and optimize build chunks ([de613d9](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/de613d909d64b4e1853032447df671939ddad9b2)) +- remove small discount badge and improve large badge UX ([822b9a6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/822b9a6265e7c9b14c4ef630e80ee81ef9597496)) +- restore page animations, improve checkbox visibility ([5ad5e8d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/5ad5e8d3657b8074305fd1c8b94cebd5c6cb4af2)) +- revert to native Telegram WebApp API, remove SDK usage ([6f8bc4f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/6f8bc4fca592f074d4705069a48bbaef3e5a105d)) +- scroll to start of tariffs section and wait for data to load ([c815ac2](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/c815ac29ea281056908f39dfb6a390b3b201377b)) +- UI improvements - reduce Android header, hide mobile scrollbar, disable animations in Telegram, consistent menu overlay ([768b340](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/768b340c35c5f145940bb9966e2e6713039be32f)) +- unify card styles across the project ([4a25d8d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4a25d8df03b26f25800f882e46223bab64873b73)) +- use native Telegram popup for email preview restriction ([7aeb47f](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/7aeb47f583056d651b90db4ed7cad2d2a2aef3b1)) +- use pill-style tabs in admin user detail page ([09584fc](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/09584fc901d778453fbda437d643f4bd9c4321e3)) +- use RemnaWave icon in admin panel menu ([4034b4d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/4034b4db3932cab273636bb54bf796aff6de712c)) +- use single shared WebSocket connection and optimize build chunks ([f6854c6](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/f6854c6c3aef3ff46d7839dfe94d2ec6bf4d7d64)) +- wrap all SDK isSupported() calls in try-catch ([e5ea09d](https://github.com/BEDOLAGA-DEV/bedolaga-cabinet/commit/e5ea09dd3a13ec47ca65de93c6f5858c1fadb135)) diff --git a/docker-compose.yml b/docker-compose.yml index 2ffd9bb..316684f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/eslint.config.js b/eslint.config.js index f33a89d..6f488b5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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, -) +); diff --git a/index.html b/index.html index 35ef396..50ae845 100644 --- a/index.html +++ b/index.html @@ -1,9 +1,12 @@ - + - + @@ -11,7 +14,9 @@ Loading... diff --git a/package-lock.json b/package-lock.json index c3b1b0e..73d9f3a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 21eeeeb..f7bd6cb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/postcss.config.js b/postcss.config.js index 2e7af2b..2aa7205 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -3,4 +3,4 @@ export default { tailwindcss: {}, autoprefixer: {}, }, -} +}; diff --git a/src/App.tsx b/src/App.tsx index 28eda68..7cd8f0f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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() { } /> + + + + + + } + /> {/* Admin routes */} ; -} - export const adminAppsApi = { - // Get full app config - getConfig: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/apps'); - return response.data; - }, - - // Get available platforms - getPlatforms: async (): Promise => { - const response = await apiClient.get('/cabinet/admin/apps/platforms'); - return response.data; - }, - - // Get apps for a platform - getPlatformApps: async (platform: string): Promise => { - const response = await apiClient.get( - `/cabinet/admin/apps/platforms/${platform}`, - ); - return response.data; - }, - - // Create a new app - createApp: async (platform: string, app: AppDefinition): Promise => { - const response = await apiClient.post( - `/cabinet/admin/apps/platforms/${platform}`, - { - platform, - app, - }, - ); - return response.data; - }, - - // Update an app - updateApp: async ( - platform: string, - appId: string, - app: AppDefinition, - ): Promise => { - const response = await apiClient.put( - `/cabinet/admin/apps/platforms/${platform}/${appId}`, - { - app, - }, - ); - return response.data; - }, - - // Delete an app - deleteApp: async (platform: string, appId: string): Promise => { - await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`); - }, - - // Reorder apps - reorderApps: async (platform: string, appIds: string[]): Promise => { - 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 => { - const response = await apiClient.get('/cabinet/admin/apps/branding'); - return response.data; - }, - - // Update branding - updateBranding: async (branding: AppConfigBranding): Promise => { - const response = await apiClient.put('/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, - branding?: AppConfigBranding, -): RemnawaveConfig => { - const platforms: Record = {}; - - for (const [platform, apps] of Object.entries(platformApps)) { - platforms[platform] = { - apps: apps.map(cabinetAppToRemnawave), - }; - } - - return { - platforms, - svgLibrary: { - download: { - svgString: - '', - }, - plus: { - svgString: - '', - }, - check: { - svgString: - '', - }, - settings: { - svgString: - '', - }, - }, - 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; branding?: AppConfigBranding } => { - const platformApps: Record = {}; - - 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 }; -}; diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx deleted file mode 100644 index 7818ce3..0000000 --- a/src/components/ConnectionModal.tsx +++ /dev/null @@ -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 = () => ( - - - -); - -const CopyIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -const LinkIcon = () => ( - - - -); - -const ChevronIcon = () => ( - - - -); - -const BackIcon = () => ( - - - -); - -// App icons -const HappIcon = () => ( - - - - - - - - -); - -const ClashMetaIcon = () => ( - - - -); - -const ShadowrocketIcon = () => ( - - - -); - -const StreisandIcon = () => ( - - - -); - -const getAppIcon = (appName: string): React.ReactNode => { - const name = appName.toLowerCase(); - if (name.includes('happ')) return ; - if (name.includes('shadowrocket') || name.includes('rocket')) return ; - if (name.includes('streisand')) return ; - if (name.includes('clash') || name.includes('meta') || name.includes('verge')) - return ; - return 📦; -}; - -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(null); - const [copied, setCopied] = useState(false); - const [showAppSelector, setShowAppSelector] = useState(false); - const [selectedPlatform, setSelectedPlatform] = useState(null); - - const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset } = - useTelegramWebApp(); - const { impact: hapticImpact } = useHaptic(); - const isMobileScreen = useIsMobile(); - const isMobile = isMobileScreen; - const scrollContainerRef = useRef(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({ - 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 = ( -
- {children} -
- ); - if (typeof document !== 'undefined') return createPortal(content, document.body); - return content; - } - - // Desktop centered - positioned higher - return ( -
-
e.stopPropagation()} - > - {children} -
-
- ); - }; - - // Loading - if (isLoading) { - return ( - -
-
-
- - ); - } - - // Error - if (error || !appConfig) { - return ( - -
-

{t('common.error')}

- -
-
- ); - } - - // No subscription - if (!appConfig.hasSubscription) { - return ( - -
-

- {t('subscription.connection.title')} -

-

{t('subscription.connection.noSubscription')}

- -
-
- ); - } - - // App selector - if (showAppSelector) { - const platformNames: Record = { - ios: 'iOS', - android: 'Android', - windows: 'Windows', - macos: 'macOS', - linux: 'Linux', - androidTV: 'Android TV', - appleTV: 'Apple TV', - }; - - const platformIcons: Record = { - ios: ( - - - - ), - android: ( - - - - ), - windows: ( - - - - ), - macos: ( - - - - ), - linux: ( - - - - ), - androidTV: ( - - - - ), - appleTV: ( - - - - ), - }; - - // Step 1: Platform selection - if (!selectedPlatform) { - return ( - -
- {!isTelegramWebApp && ( - - )} -

- {t('subscription.connection.selectPlatform')} -

-
-
- {availablePlatforms.map((platform) => { - const apps = appConfig.platforms[platform]; - if (!apps?.length) return null; - const isCurrentPlatform = platform === detectedPlatform; - const appCount = apps.length; - - return ( - - ); - })} -
-
- ); - } - - // Step 2: App selection for chosen platform - const apps = appConfig.platforms[selectedPlatform] || []; - const isCurrentPlatform = selectedPlatform === detectedPlatform; - - return ( - -
- {!isTelegramWebApp && ( - - )} -
-

- {platformNames[selectedPlatform] || selectedPlatform} -

- {isCurrentPlatform && ( - - {t('subscription.connection.yourDevice')} - - )} -
-
-
-
- {apps.map((app) => { - const isSelected = selectedApp?.id === app.id; - return ( - - ); - })} -
-
-
- ); - } - - // Main view - return ( - -
-
-

{t('subscription.connection.title')}

- {!isTelegramWebApp && ( - - )} -
- -
- -
- {selectedApp?.installationStep && ( -
-
- - 1 - - - {t('subscription.connection.installApp')} - -
-

- {getLocalizedText(selectedApp.installationStep.description)} -

- {selectedApp.installationStep.buttons && - selectedApp.installationStep.buttons.length > 0 && ( -
- {selectedApp.installationStep.buttons - .filter((btn) => isValidExternalUrl(btn.buttonLink)) - .map((btn, idx) => ( - - {getLocalizedText(btn.buttonText)} - - ))} -
- )} -
- )} - - {selectedApp?.addSubscriptionStep && ( -
-
- - 2 - - - {t('subscription.connection.addSubscription')} - -
-

- {getLocalizedText(selectedApp.addSubscriptionStep.description)} -

-
- {selectedApp.deepLink && ( - - )} - {/* Copy link button - hidden when hideLink is true */} - {!appConfig?.hideLink && ( - - )} -
-
- )} - - {selectedApp?.connectAndUseStep && ( -
-
- - 3 - - - {t('subscription.connection.connectVpn')} - -
-

- {getLocalizedText(selectedApp.connectAndUseStep.description)} -

-
- )} -
-
- ); -} diff --git a/src/components/admin/ThemeTab.tsx b/src/components/admin/ThemeTab.tsx index e31a59a..380d851 100644 --- a/src/components/admin/ThemeTab.tsx +++ b/src/components/admin/ThemeTab.tsx @@ -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(DEFAULT_THEME_COLORS); + const savedColorsRef = useRef(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) => { + 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) => (
@@ -203,27 +318,23 @@ export function ThemeTab() {
updateColorsMutation.mutate({ darkBackground: color })} - disabled={updateColorsMutation.isPending} + value={draftColors.darkBackground} + onChange={(color) => updateDraftColor('darkBackground', color)} /> updateColorsMutation.mutate({ darkSurface: color })} - disabled={updateColorsMutation.isPending} + value={draftColors.darkSurface} + onChange={(color) => updateDraftColor('darkSurface', color)} /> updateColorsMutation.mutate({ darkText: color })} - disabled={updateColorsMutation.isPending} + value={draftColors.darkText} + onChange={(color) => updateDraftColor('darkText', color)} /> updateColorsMutation.mutate({ darkTextSecondary: color })} - disabled={updateColorsMutation.isPending} + value={draftColors.darkTextSecondary} + onChange={(color) => updateDraftColor('darkTextSecondary', color)} />
@@ -236,27 +347,23 @@ export function ThemeTab() {
updateColorsMutation.mutate({ lightBackground: color })} - disabled={updateColorsMutation.isPending} + value={draftColors.lightBackground} + onChange={(color) => updateDraftColor('lightBackground', color)} /> updateColorsMutation.mutate({ lightSurface: color })} - disabled={updateColorsMutation.isPending} + value={draftColors.lightSurface} + onChange={(color) => updateDraftColor('lightSurface', color)} /> updateColorsMutation.mutate({ lightText: color })} - disabled={updateColorsMutation.isPending} + value={draftColors.lightText} + onChange={(color) => updateDraftColor('lightText', color)} /> updateColorsMutation.mutate({ lightTextSecondary: color })} - disabled={updateColorsMutation.isPending} + value={draftColors.lightTextSecondary} + onChange={(color) => updateDraftColor('lightTextSecondary', color)} />
@@ -269,33 +376,52 @@ export function ThemeTab() {
updateColorsMutation.mutate({ success: color })} - disabled={updateColorsMutation.isPending} + value={draftColors.success} + onChange={(color) => updateDraftColor('success', color)} /> updateColorsMutation.mutate({ warning: color })} - disabled={updateColorsMutation.isPending} + value={draftColors.warning} + onChange={(color) => updateDraftColor('warning', color)} /> updateColorsMutation.mutate({ error: color })} - disabled={updateColorsMutation.isPending} + value={draftColors.error} + onChange={(color) => updateDraftColor('error', color)} />
- {/* Reset button */} - + {/* Save / Cancel / Reset buttons */} +
+ {hasUnsavedChanges && ( + <> + + + + )} + +
)} diff --git a/src/components/connection/InstallationGuide.tsx b/src/components/connection/InstallationGuide.tsx new file mode 100644 index 0000000..cc2198d --- /dev/null +++ b/src/components/connection/InstallationGuide.tsx @@ -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> = { + cards: CardsBlock, + timeline: TimelineBlock, + accordion: AccordionBlock, + minimal: MinimalBlock, +}; + +// Icons +const CopyIcon = () => ( + + + +); + +const CheckIcon = () => ( + + + +); + +const BackIcon = () => ( + + + +); + +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(null); + const [selectedApp, setSelectedApp] = useState(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 ( +
+ {buttons.map((btn, idx) => { + const btnText = getLocalizedText(btn.text); + const btnSvg = getSvgHtml(btn.svgIconKey); + const btnIcon = 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 ( + + ); + } + + if (btn.type === 'copyButton') { + if (appConfig.hideLink) return null; + const url = btn.resolvedUrl || appConfig.subscriptionUrl; + if (!url) return null; + return ( + + ); + } + + // external + const href = btn.link || btn.url || ''; + if (!isValidExternalUrl(href)) return null; + return ( + + {btnIcon} + {btnText} + + ); + })} +
+ ); + }, + [ + 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 = { + 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 ( +
+ {/* Header + platform dropdown */} +
+ {!isTelegramWebApp && ( + + )} +

+ {getBaseTranslation('installationGuideHeader', 'subscription.connection.title')} +

+ {availablePlatforms.length > 1 && ( +
+ {currentPlatformSvg && ( +
+ )} + +
+ + + +
+
+ )} +
+ + {/* App chips */} + {currentPlatformApps.length > 0 && ( +
+ {currentPlatformApps.map((app, idx) => { + const isSelected = selectedApp?.name === app.name; + const appIconSvg = getSvgHtml(app.svgIconKey); + return ( + + ); + })} +
+ )} + + {/* Tutorial button */} + {appConfig.baseSettings?.isShowTutorialButton && appConfig.baseSettings?.tutorialUrl && ( + + + + + {getBaseTranslation('tutorial', 'subscription.connection.tutorial')} + + )} + + {/* Blocks */} + {selectedApp && ( + + )} +
+ ); +} diff --git a/src/components/connection/blocks/AccordionBlock.tsx b/src/components/connection/blocks/AccordionBlock.tsx new file mode 100644 index 0000000..c1d75e2 --- /dev/null +++ b/src/components/connection/blocks/AccordionBlock.tsx @@ -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(0); + + return ( +
+ {blocks.map((block, index) => { + const gradientStyle = getColorGradient(block.svgIconColor || 'cyan'); + const isOpen = openIndex === index; + + return ( +
+ {/* Control */} + + {/* Panel */} +
+
+

+ {getLocalizedText(block.description)} +

+ {renderBlockButtons(block.buttons, 'light')} +
+
+
+ ); + })} +
+ ); +} diff --git a/src/components/connection/blocks/CardsBlock.tsx b/src/components/connection/blocks/CardsBlock.tsx new file mode 100644 index 0000000..54e7bcf --- /dev/null +++ b/src/components/connection/blocks/CardsBlock.tsx @@ -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 ( +
+ {blocks.map((block, index) => { + const gradientStyle = getColorGradient(block.svgIconColor || 'cyan'); + + return ( +
+
+ +
+

{getLocalizedText(block.title)}

+

+ {getLocalizedText(block.description)} +

+ {renderBlockButtons(block.buttons, 'light')} +
+
+
+ ); + })} +
+ ); +} diff --git a/src/components/connection/blocks/MinimalBlock.tsx b/src/components/connection/blocks/MinimalBlock.tsx new file mode 100644 index 0000000..c5578c4 --- /dev/null +++ b/src/components/connection/blocks/MinimalBlock.tsx @@ -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 ( +
+ {blocks.map((block, index) => { + const gradientStyle = getColorGradient(block.svgIconColor || 'cyan'); + const isLast = index === blocks.length - 1; + + return ( +
+
+ + {getLocalizedText(block.title)} +
+

+ {getLocalizedText(block.description)} +

+ {renderBlockButtons(block.buttons, 'subtle')} +
+ ); + })} +
+ ); +} diff --git a/src/components/connection/blocks/ThemeIcon.tsx b/src/components/connection/blocks/ThemeIcon.tsx new file mode 100644 index 0000000..e14d95b --- /dev/null +++ b/src/components/connection/blocks/ThemeIcon.tsx @@ -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 ( +
+
+
+ ); +} diff --git a/src/components/connection/blocks/TimelineBlock.tsx b/src/components/connection/blocks/TimelineBlock.tsx new file mode 100644 index 0000000..fc41634 --- /dev/null +++ b/src/components/connection/blocks/TimelineBlock.tsx @@ -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 ( +
+ {blocks.map((block, index) => { + const gradientStyle = getColorGradientSolid(block.svgIconColor || 'cyan'); + const isLast = index === blocks.length - 1; + + return ( +
+ {/* Left column: bullet + line segment */} +
+ + {!isLast &&
} +
+ {/* Right column: content */} +
+

{getLocalizedText(block.title)}

+

+ {getLocalizedText(block.description)} +

+ {renderBlockButtons(block.buttons, 'light')} +
+
+ ); + })} +
+ ); +} diff --git a/src/components/connection/blocks/index.ts b/src/components/connection/blocks/index.ts new file mode 100644 index 0000000..6e9e832 --- /dev/null +++ b/src/components/connection/blocks/index.ts @@ -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'; diff --git a/src/components/connection/blocks/types.ts b/src/components/connection/blocks/types.ts new file mode 100644 index 0000000..d1592f2 --- /dev/null +++ b/src/components/connection/blocks/types.ts @@ -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; +} diff --git a/src/locales/en.json b/src/locales/en.json index b4eabe4..a12b571 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 4eb2d39..b4fa28b 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -298,7 +298,8 @@ "changeApp": "تغییر برنامه", "selectPlatform": "انتخاب پلتفرم", "app": "برنامه", - "apps": "برنامه" + "apps": "برنامه", + "tutorial": "آموزش" }, "additionalOptions": { "title": "گزینه‌های اضافی", diff --git a/src/locales/ru.json b/src/locales/ru.json index 4a48ae4..97007ec 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -368,7 +368,8 @@ "changeApp": "Сменить приложение", "selectPlatform": "Выберите платформу", "app": "приложение", - "apps": "приложений" + "apps": "приложений", + "tutorial": "Инструкция" }, "myDevices": "Мои устройства", "noDevices": "Нет подключенных устройств", diff --git a/src/locales/zh.json b/src/locales/zh.json index adbae2a..fbd16d3 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -298,7 +298,8 @@ "changeApp": "更换应用", "selectPlatform": "选择平台", "app": "个应用", - "apps": "个应用" + "apps": "个应用", + "tutorial": "教程" }, "additionalOptions": { "title": "附加选项", diff --git a/src/pages/AdminApps.tsx b/src/pages/AdminApps.tsx index 8ed24d2..e80045c 100644 --- a/src/pages/AdminApps.tsx +++ b/src/pages/AdminApps.tsx @@ -1,1220 +1,122 @@ -import { useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { - adminAppsApi, - AppDefinition, - LocalizedText, - AppStep, - AppButton, - exportToRemnawaveFormat, - importFromRemnawaveFormat, - RemnawaveConfig, - importFromRemnawaveFormat as convertRemnawave, -} from '../api/adminApps'; +import { adminAppsApi } from '../api/adminApps'; import { useBackButton } from '../platform/hooks/useBackButton'; import { usePlatform } from '../platform/hooks/usePlatform'; -// Icons - -const BackIcon = () => ( - - - -); - -const AppsIcon = () => ( - - - -); - -const PlusIcon = () => ( - - - -); - -const EditIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const ChevronUpIcon = () => ( - - - -); - -const ChevronDownIcon = () => ( - - - -); - -const CopyIcon = () => ( - - - -); - -const StarIcon = ({ filled }: { filled: boolean }) => ( - - - -); - -const DownloadIcon = () => ( - - - -); - -const UploadIcon = () => ( - - - -); - -const CloudSyncIcon = () => ( - - - -); - -const RefreshIcon = () => ( - - - -); - -const SettingsIcon = () => ( - - - - -); - -const PLATFORM_LABELS: Record = { - ios: 'iOS', - android: 'Android', - macos: 'macOS', - windows: 'Windows', - linux: 'Linux', - androidTV: 'Android TV', - appleTV: 'Apple TV', -}; - -const PLATFORMS = ['ios', 'android', 'macos', 'windows', 'linux', 'androidTV', 'appleTV']; - -// Helper to create empty localized text -const emptyLocalizedText = (): LocalizedText => ({ - en: '', - ru: '', - zh: '', - fa: '', -}); - -// Helper to create empty app step -const emptyAppStep = (): AppStep => ({ - description: emptyLocalizedText(), - buttons: [], -}); - -// Helper to create empty app -const createEmptyApp = (platform: string): AppDefinition => ({ - id: `new-app-${platform}-${Date.now()}`, - name: '', - isFeatured: false, - urlScheme: '', - installationStep: emptyAppStep(), - addSubscriptionStep: emptyAppStep(), - connectAndUseStep: emptyAppStep(), -}); - -interface AppEditorModalProps { - app: AppDefinition; - platform: string; - isNew: boolean; - onSave: (app: AppDefinition) => void; - onClose: () => void; -} - -function AppEditorModal({ app, platform, isNew, onSave, onClose }: AppEditorModalProps) { - const { t } = useTranslation(); - const [editedApp, setEditedApp] = useState({ ...app }); - const [activeTab, setActiveTab] = useState< - 'basic' | 'installation' | 'subscription' | 'connect' | 'additional' - >('basic'); - - const updateField = (field: K, value: AppDefinition[K]) => { - setEditedApp((prev) => ({ ...prev, [field]: value })); - }; - - const updateLocalizedText = ( - stepKey: - | 'installationStep' - | 'addSubscriptionStep' - | 'connectAndUseStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - field: 'description' | 'title', - lang: keyof LocalizedText, - value: string, - ) => { - setEditedApp((prev) => { - const step = prev[stepKey] || emptyAppStep(); - const fieldValue = step[field] || emptyLocalizedText(); - return { - ...prev, - [stepKey]: { - ...step, - [field]: { ...fieldValue, [lang]: value }, - }, - }; - }); - }; - - const updateButtons = ( - stepKey: - | 'installationStep' - | 'addSubscriptionStep' - | 'connectAndUseStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - buttons: AppButton[], - ) => { - setEditedApp((prev) => { - const step = prev[stepKey] || emptyAppStep(); - return { - ...prev, - [stepKey]: { ...step, buttons }, - }; - }); - }; - - const addButton = ( - stepKey: - | 'installationStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - ) => { - const step = editedApp[stepKey] || emptyAppStep(); - const buttons = step.buttons || []; - // Generate unique id for React key - const newButton: AppButton = { - id: `btn-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, - buttonLink: '', - buttonText: emptyLocalizedText(), - }; - updateButtons(stepKey, [...buttons, newButton]); - }; - - const removeButton = ( - stepKey: - | 'installationStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - index: number, - ) => { - const step = editedApp[stepKey] || emptyAppStep(); - const buttons = step.buttons || []; - updateButtons( - stepKey, - buttons.filter((_, i) => i !== index), - ); - }; - - const updateButton = ( - stepKey: - | 'installationStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - index: number, - field: 'buttonLink' | 'buttonText', - value: string | LocalizedText, - ) => { - const step = editedApp[stepKey] || emptyAppStep(); - const buttons = [...(step.buttons || [])]; - buttons[index] = { ...buttons[index], [field]: value }; - updateButtons(stepKey, buttons); - }; - - const renderLocalizedTextInputs = ( - stepKey: - | 'installationStep' - | 'addSubscriptionStep' - | 'connectAndUseStep' - | 'additionalBeforeAddSubscriptionStep' - | 'additionalAfterAddSubscriptionStep', - field: 'description' | 'title', - label: string, - ) => { - const step = editedApp[stepKey]; - const value = step?.[field] || emptyLocalizedText(); - - return ( -
- - {(['en', 'ru'] as const).map((lang) => ( -
- {lang} -