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/.gitignore b/.gitignore index d3b91dc..c9836a3 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ miniapp/ # AI & Agents context .ai/ +CLAUDE.md +**/CLAUDE.md 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..1d3cf25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@lottiefiles/dotlottie-react": "^0.8.0", + "@kastov/cryptohapp": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -25,40 +25,42 @@ "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.8.0", "@telegram-apps/sdk-react": "^3.3.9", + "autoprefixer": "^10.4.24", "axios": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "dompurify": "^3.3.1", "framer-motion": "^12.29.2", - "i18next": "^23.7.0", - "i18next-browser-languagedetector": "^7.2.0", + "i18next": "^25.8.4", + "i18next-browser-languagedetector": "^8.2.0", + "jsencrypt": "^3.5.4", "ogl": "^1.0.11", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-i18next": "^13.5.0", - "react-router-dom": "^6.30.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-i18next": "^16.5.4", + "react-router": "^7.13.0", "tailwind-merge": "^3.4.0", - "zustand": "^4.4.0" + "zustand": "^5.0.11" }, "devDependencies": { "@eslint/js": "^9.39.2", "@types/dompurify": "^3.0.5", - "@types/react": "^18.2.37", - "@types/react-dom": "^18.2.15", + "@types/node": "^25.2.1", + "@types/react": "^19.2.13", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", - "autoprefixer": "^10.4.16", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.26", + "eslint-plugin-react-refresh": "^0.5.0", "globals": "^17.1.0", "husky": "^9.1.7", "lint-staged": "^16.2.7", "postcss": "^8.4.31", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", - "tailwindcss": "^3.3.5", + "tailwindcss": "^3.4.19", "typescript": "^5.2.2", "typescript-eslint": "^8.54.0", "vite": "^7.3.1" @@ -78,13 +80,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -93,9 +95,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -103,22 +105,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -134,25 +136,15 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -162,13 +154,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -178,16 +170,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -199,29 +181,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -231,9 +213,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", "engines": { @@ -271,27 +253,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -333,42 +315,42 @@ } }, "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -376,9 +358,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -1182,25 +1164,20 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@lottiefiles/dotlottie-react": { - "version": "0.8.12", - "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.8.12.tgz", - "integrity": "sha512-6S9q5mH2l8JDnKAruAq4NP7bzHVzJNUt8UsXONfveSyJPUgT5hGw5b5vCjW19Ra3hgN/d69qUtRLHfmyRAgblQ==", + "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", - "dependencies": { - "@lottiefiles/dotlottie-web": "0.33.0" - }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + "jsencrypt": ">=3" + }, + "peerDependenciesMeta": { + "jsencrypt": { + "optional": true + } } }, - "node_modules/@lottiefiles/dotlottie-web": { - "version": "0.33.0", - "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.33.0.tgz", - "integrity": "sha512-/KerIs0UkmwuDKYWlNGWYSSZkZaLNXXHqg6qCnP7P2JuzgI4bHRmlIk3+LoJjl/jWqd3WyI4t5oRV6ch0sQwvg==", - "license": "MIT" - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2266,26 +2243,17 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, - "node_modules/@remix-run/router": { - "version": "1.23.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", - "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", - "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "cpu": [ "arm" ], @@ -2297,9 +2265,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", - "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "cpu": [ "arm64" ], @@ -2311,9 +2279,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", - "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "cpu": [ "arm64" ], @@ -2325,9 +2293,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", - "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "cpu": [ "x64" ], @@ -2339,9 +2307,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", - "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "cpu": [ "arm64" ], @@ -2353,9 +2321,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", - "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "cpu": [ "x64" ], @@ -2367,9 +2335,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", - "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "cpu": [ "arm" ], @@ -2381,9 +2349,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", - "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "cpu": [ "arm" ], @@ -2395,9 +2363,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", - "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "cpu": [ "arm64" ], @@ -2409,9 +2377,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", - "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "cpu": [ "arm64" ], @@ -2423,9 +2391,23 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", - "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "cpu": [ "loong64" ], @@ -2437,9 +2419,23 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", - "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "cpu": [ "ppc64" ], @@ -2451,9 +2447,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", - "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "cpu": [ "riscv64" ], @@ -2465,9 +2461,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", - "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "cpu": [ "riscv64" ], @@ -2479,9 +2475,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", - "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "cpu": [ "s390x" ], @@ -2493,9 +2489,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", - "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], @@ -2507,9 +2503,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", - "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "cpu": [ "x64" ], @@ -2520,10 +2516,24 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", - "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "cpu": [ "arm64" ], @@ -2535,9 +2545,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", - "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "cpu": [ "arm64" ], @@ -2549,9 +2559,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", - "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "cpu": [ "ia32" ], @@ -2563,9 +2573,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", - "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "cpu": [ "x64" ], @@ -2577,9 +2587,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", - "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "cpu": [ "x64" ], @@ -2591,9 +2601,9 @@ ] }, "node_modules/@tanstack/query-core": { - "version": "5.90.16", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", - "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", "license": "MIT", "funding": { "type": "github", @@ -2601,12 +2611,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.16", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", - "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz", + "integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.90.16" + "@tanstack/query-core": "5.90.20" }, "funding": { "type": "github", @@ -2639,6 +2649,23 @@ "integrity": "sha512-bqNgF/J8Po7ZtsELm3E1a6aPr7awwxO3sIqD8J6u12urOlGoW5+1KxKKbkPa58mgXuQdsltd8apI+OVy0IYiUA==", "license": "MIT" }, + "node_modules/@telegram-apps/sdk": { + "version": "3.11.8", + "resolved": "https://registry.npmjs.org/@telegram-apps/sdk/-/sdk-3.11.8.tgz", + "integrity": "sha512-vlpkYzMJCV9Cadsn9Q+/hbHNR39j5o96N9z4CjZ6AFHkkxOeOqVRrq9zRBw0gmffROkF2bU0WQxhRj8KZ/bPhw==", + "license": "MIT", + "dependencies": { + "@telegram-apps/bridge": "^2.11.0", + "@telegram-apps/navigation": "^1.0.14", + "@telegram-apps/signals": "^1.1.2", + "@telegram-apps/toolkit": "^2.1.3", + "@telegram-apps/transformers": "^2.2.6", + "@telegram-apps/types": "^2.0.3", + "better-promises": "^0.4.1", + "error-kid": "^0.0.7", + "valibot": "1.0.0" + } + }, "node_modules/@telegram-apps/sdk-react": { "version": "3.3.9", "resolved": "https://registry.npmjs.org/@telegram-apps/sdk-react/-/sdk-react-3.3.9.tgz", @@ -2657,23 +2684,6 @@ } } }, - "node_modules/@telegram-apps/sdk-react/node_modules/@telegram-apps/sdk": { - "version": "3.11.8", - "resolved": "https://registry.npmjs.org/@telegram-apps/sdk/-/sdk-3.11.8.tgz", - "integrity": "sha512-vlpkYzMJCV9Cadsn9Q+/hbHNR39j5o96N9z4CjZ6AFHkkxOeOqVRrq9zRBw0gmffROkF2bU0WQxhRj8KZ/bPhw==", - "license": "MIT", - "dependencies": { - "@telegram-apps/bridge": "^2.11.0", - "@telegram-apps/navigation": "^1.0.14", - "@telegram-apps/signals": "^1.1.2", - "@telegram-apps/toolkit": "^2.1.3", - "@telegram-apps/transformers": "^2.2.6", - "@telegram-apps/types": "^2.0.3", - "better-promises": "^0.4.1", - "error-kid": "^0.0.7", - "valibot": "1.0.0" - } - }, "node_modules/@telegram-apps/signals": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@telegram-apps/signals/-/signals-1.1.2.tgz", @@ -2788,34 +2798,37 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, - "license": "MIT" + "node_modules/@types/node": { + "version": "25.2.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz", + "integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } }, "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "version": "19.2.13", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", + "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", "devOptional": true, "license": "MIT", "peer": true, "dependencies": { - "@types/prop-types": "*", "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", "peer": true, "peerDependencies": { - "@types/react": "^18.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/trusted-types": { @@ -3040,6 +3053,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/utils": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.54.0.tgz", @@ -3083,16 +3109,16 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.2.tgz", - "integrity": "sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==", + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.3.tgz", + "integrity": "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.5", + "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.53", + "@rolldown/pluginutils": "1.0.0-rc.2", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, @@ -3145,9 +3171,9 @@ } }, "node_modules/ansi-escapes": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", - "integrity": "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw==", + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", "dev": true, "license": "MIT", "dependencies": { @@ -3243,10 +3269,9 @@ "license": "MIT" }, "node_modules/autoprefixer": { - "version": "10.4.23", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", - "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", - "dev": true, + "version": "10.4.24", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", "funding": [ { "type": "opencollective", @@ -3264,7 +3289,7 @@ "license": "MIT", "dependencies": { "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001760", + "caniuse-lite": "^1.0.30001766", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -3280,9 +3305,9 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", + "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", @@ -3298,10 +3323,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", - "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", - "dev": true, + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" @@ -3357,7 +3381,6 @@ "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3422,10 +3445,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001762", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz", - "integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==", - "dev": true, + "version": "1.0.30001768", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz", + "integrity": "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA==", "funding": [ { "type": "opencollective", @@ -3607,13 +3629,13 @@ } }, "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 6" + "node": ">=20" } }, "node_modules/concat-map": { @@ -3630,6 +3652,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3743,10 +3778,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true, + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -3866,7 +3900,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3983,13 +4016,13 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.26", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", - "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.0.tgz", + "integrity": "sha512-ZYvmh7VfVgqR/7wR71I3Zl6hK/C5CcxdWYKZSpHawS5JCNgE4efhQWg/+/WPpgGAp9Ngp/rRZYyaIwmPQBq/lA==", "dev": true, "license": "MIT", "peerDependencies": { - "eslint": ">=8.40" + "eslint": ">=9" } }, "node_modules/eslint-scope": { @@ -4258,7 +4291,6 @@ "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -4269,12 +4301,12 @@ } }, "node_modules/framer-motion": { - "version": "12.29.2", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.29.2.tgz", - "integrity": "sha512-lSNRzBJk4wuIy0emYQ/nfZ7eWhqud2umPKw2QAQki6uKhZPKm2hRQHeQoHTG9MIvfobb+A/LbEWPJU794ZUKrg==", + "version": "12.33.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.33.0.tgz", + "integrity": "sha512-ca8d+rRPcDP5iIF+MoT3WNc0KHJMjIyFAbtVLvM9eA7joGSpeqDfiNH/kCs1t4CHi04njYvWyj0jS4QlEK/rJQ==", "license": "MIT", "dependencies": { - "motion-dom": "^12.29.2", + "motion-dom": "^12.33.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, @@ -4402,9 +4434,9 @@ } }, "node_modules/globals": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.1.0.tgz", - "integrity": "sha512-8HoIcWI5fCvG5NADj4bDav+er9B9JMj2vyL2pI8D0eismKyUvPLTSs+Ln3wqhwcp306i73iyVnEKx3F6T47TGw==", + "version": "17.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz", + "integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==", "dev": true, "license": "MIT", "engines": { @@ -4518,9 +4550,9 @@ } }, "node_modules/i18next": { - "version": "23.16.8", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz", - "integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==", + "version": "25.8.4", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.4.tgz", + "integrity": "sha512-a9A0MnUjKvzjEN/26ZY1okpra9kA8MEwzYEz1BNm+IyxUKPRH6ihf0p7vj8YvULwZHKHl3zkJ6KOt4hewxBecQ==", "funding": [ { "type": "individual", @@ -4538,13 +4570,21 @@ "license": "MIT", "peer": true, "dependencies": { - "@babel/runtime": "^7.23.2" + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } }, "node_modules/i18next-browser-languagedetector": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-7.2.2.tgz", - "integrity": "sha512-6b7r75uIJDWCcCflmbof+sJ94k9UQO4X0YR62oUfqGI/GjCLVzlCwu8TFdRZIqVLzWbzNcmkmhfqKEr4TLz4HQ==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.23.2" @@ -4687,6 +4727,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4702,6 +4743,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", @@ -4818,16 +4866,6 @@ "url": "https://opencollective.com/lint-staged" } }, - "node_modules/lint-staged/node_modules/commander": { - "version": "14.0.2", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", - "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, "node_modules/listr2": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", @@ -4889,18 +4927,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4998,9 +5024,9 @@ "license": "MIT" }, "node_modules/motion-dom": { - "version": "12.29.2", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.29.2.tgz", - "integrity": "sha512-/k+NuycVV8pykxyiTCoFzIVLA95Nb1BFIVvfSu9L50/6K6qNeAYtkxXILy/LRutt7AzaYDc2myj0wkCVVYAPPA==", + "version": "12.33.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.33.0.tgz", + "integrity": "sha512-XRPebVypsl0UM+7v0Hr8o9UAj0S2djsQWRdHBd5iVouVpMrQqAI0C/rDAT3QaYnXnHuC5hMcwDHCboNeyYjPoQ==", "license": "MIT", "dependencies": { "motion-utils": "^12.29.2" @@ -5048,7 +5074,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -5074,7 +5099,6 @@ "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true, "license": "MIT" }, "node_modules/normalize-path": { @@ -5223,7 +5247,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -5276,7 +5299,6 @@ "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -5433,7 +5455,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -5580,44 +5601,42 @@ "license": "MIT" }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", "peer": true, - "dependencies": { - "loose-envify": "^1.1.0" - }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", "peer": true, "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.2.4" } }, "node_modules/react-i18next": { - "version": "13.5.0", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-13.5.0.tgz", - "integrity": "sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA==", + "version": "16.5.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.4.tgz", + "integrity": "sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.22.5", - "html-parse-stringify": "^3.0.1" + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" }, "peerDependencies": { - "i18next": ">= 23.2.3", - "react": ">= 16.8.0" + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" }, "peerDependenciesMeta": { "react-dom": { @@ -5625,6 +5644,9 @@ }, "react-native": { "optional": true + }, + "typescript": { + "optional": true } } }, @@ -5686,36 +5708,25 @@ } }, "node_modules/react-router": { - "version": "6.30.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", - "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.2" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8" - } - }, - "node_modules/react-router-dom": { - "version": "6.30.3", - "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" + "react": ">=18", + "react-dom": ">=18" }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "node_modules/react-style-singleton": { @@ -5830,9 +5841,9 @@ "license": "MIT" }, "node_modules/rollup": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", - "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "dev": true, "license": "MIT", "dependencies": { @@ -5846,28 +5857,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.54.0", - "@rollup/rollup-android-arm64": "4.54.0", - "@rollup/rollup-darwin-arm64": "4.54.0", - "@rollup/rollup-darwin-x64": "4.54.0", - "@rollup/rollup-freebsd-arm64": "4.54.0", - "@rollup/rollup-freebsd-x64": "4.54.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", - "@rollup/rollup-linux-arm-musleabihf": "4.54.0", - "@rollup/rollup-linux-arm64-gnu": "4.54.0", - "@rollup/rollup-linux-arm64-musl": "4.54.0", - "@rollup/rollup-linux-loong64-gnu": "4.54.0", - "@rollup/rollup-linux-ppc64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-musl": "4.54.0", - "@rollup/rollup-linux-s390x-gnu": "4.54.0", - "@rollup/rollup-linux-x64-gnu": "4.54.0", - "@rollup/rollup-linux-x64-musl": "4.54.0", - "@rollup/rollup-openharmony-arm64": "4.54.0", - "@rollup/rollup-win32-arm64-msvc": "4.54.0", - "@rollup/rollup-win32-ia32-msvc": "4.54.0", - "@rollup/rollup-win32-x64-gnu": "4.54.0", - "@rollup/rollup-win32-x64-msvc": "4.54.0", + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" } }, @@ -5896,27 +5910,27 @@ } }, "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5987,7 +6001,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -6004,9 +6017,9 @@ } }, "node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz", + "integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==", "dev": true, "license": "MIT", "dependencies": { @@ -6072,6 +6085,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6309,11 +6332,17 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, "funding": [ { "type": "opencollective", @@ -6676,20 +6705,18 @@ } }, "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, "engines": { - "node": ">=12.7.0" + "node": ">=12.20.0" }, "peerDependencies": { - "@types/react": ">=16.8", + "@types/react": ">=18.0.0", "immer": ">=9.0.6", - "react": ">=16.8" + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" }, "peerDependenciesMeta": { "@types/react": { @@ -6700,6 +6727,9 @@ }, "react": { "optional": true + }, + "use-sync-external-store": { + "optional": true } } } diff --git a/package.json b/package.json index 21eeeeb..488d7c2 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@lottiefiles/dotlottie-react": "^0.8.0", + "@kastov/cryptohapp": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -33,40 +33,42 @@ "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.8.0", "@telegram-apps/sdk-react": "^3.3.9", + "autoprefixer": "^10.4.24", "axios": "^1.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "dompurify": "^3.3.1", "framer-motion": "^12.29.2", - "i18next": "^23.7.0", - "i18next-browser-languagedetector": "^7.2.0", + "i18next": "^25.8.4", + "i18next-browser-languagedetector": "^8.2.0", + "jsencrypt": "^3.5.4", "ogl": "^1.0.11", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-i18next": "^13.5.0", - "react-router-dom": "^6.30.3", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-i18next": "^16.5.4", + "react-router": "^7.13.0", "tailwind-merge": "^3.4.0", - "zustand": "^4.4.0" + "zustand": "^5.0.11" }, "devDependencies": { "@eslint/js": "^9.39.2", "@types/dompurify": "^3.0.5", - "@types/react": "^18.2.37", - "@types/react-dom": "^18.2.15", + "@types/node": "^25.2.1", + "@types/react": "^19.2.13", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", - "autoprefixer": "^10.4.16", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.26", + "eslint-plugin-react-refresh": "^0.5.0", "globals": "^17.1.0", "husky": "^9.1.7", "lint-staged": "^16.2.7", "postcss": "^8.4.31", "prettier": "^3.8.1", "prettier-plugin-tailwindcss": "^0.7.2", - "tailwindcss": "^3.3.5", + "tailwindcss": "^3.4.19", "typescript": "^5.2.2", "typescript-eslint": "^8.54.0", "vite": "^7.3.1" 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..8d1ba28 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,10 +1,14 @@ import { lazy, Suspense } from 'react'; -import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { Routes, Route, Navigate, useLocation } from 'react-router'; import { useAuthStore } from './store/auth'; import { useBlockingStore } from './store/blocking'; import Layout from './components/layout/Layout'; import PageLoader from './components/common/PageLoader'; -import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking'; +import { + MaintenanceScreen, + ChannelSubscriptionScreen, + BlacklistedScreen, +} from './components/blocking'; import { saveReturnUrl } from './utils/token'; import { useAnalyticsCounters } from './hooks/useAnalyticsCounters'; // Auth pages - load immediately (small) @@ -26,6 +30,7 @@ const Contests = lazy(() => import('./pages/Contests')); const Polls = lazy(() => import('./pages/Polls')); const Info = lazy(() => import('./pages/Info')); const Wheel = lazy(() => import('./pages/Wheel')); +const Connection = lazy(() => import('./pages/Connection')); const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect')); const TopUpAmount = lazy(() => import('./pages/TopUpAmount')); @@ -121,6 +126,10 @@ function BlockingOverlay() { return ; } + if (blockingType === 'blacklisted') { + return ; + } + return null; } @@ -262,6 +271,16 @@ function App() { } /> + + + + + + } + /> {/* Admin routes */} {isTelegram && } - - - - - - - - - - - + + + + + + + + + + + + + ); } diff --git a/src/api/adminApps.ts b/src/api/adminApps.ts index 414f7c4..2b6d2c9 100644 --- a/src/api/adminApps.ts +++ b/src/api/adminApps.ts @@ -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; -} - 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/api/client.ts b/src/api/client.ts index 49d7b0a..9dcdc85 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -7,6 +7,7 @@ import { safeRedirectToLogin, } from '../utils/token'; import { useBlockingStore } from '../store/blocking'; +import { API } from '../config/constants'; const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'; @@ -57,31 +58,53 @@ const getTelegramInitData = (): string | null => { export const apiClient = axios.create({ baseURL: API_BASE_URL, + timeout: API.TIMEOUT_MS, headers: { 'Content-Type': 'application/json', }, }); +// Auth endpoints that don't need Bearer token or token refresh +const AUTH_ENDPOINTS = [ + '/cabinet/auth/telegram', + '/cabinet/auth/telegram/widget', + '/cabinet/auth/email/login', + '/cabinet/auth/email/register', + '/cabinet/auth/email/verify', + '/cabinet/auth/refresh', + '/cabinet/auth/password/forgot', + '/cabinet/auth/password/reset', +]; + +function isAuthEndpoint(url: string | undefined): boolean { + if (!url) return false; + return AUTH_ENDPOINTS.some((endpoint) => url.includes(endpoint)); +} + // Request interceptor - add auth token with expiration check apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => { - let token = tokenStorage.getAccessToken(); + // Skip token refresh and Bearer header for auth endpoints + // These endpoints authenticate via init_data/credentials, not Bearer tokens + if (!isAuthEndpoint(config.url)) { + let token = tokenStorage.getAccessToken(); - // Проверяем срок действия токена перед запросом - if (token && isTokenExpired(token)) { - // Используем централизованный менеджер для refresh - const newToken = await tokenRefreshManager.refreshAccessToken(); - if (newToken) { - token = newToken; - } else { - // Refresh не удался - редирект на логин - tokenStorage.clearTokens(); - safeRedirectToLogin(); - return config; + // Проверяем срок действия токена перед запросом + if (token && isTokenExpired(token)) { + // Используем централизованный менеджер для refresh + const newToken = await tokenRefreshManager.refreshAccessToken(); + if (newToken) { + token = newToken; + } else { + // Refresh не удался - редирект на логин + tokenStorage.clearTokens(); + safeRedirectToLogin(); + return config; + } } - } - if (token && config.headers) { - config.headers.Authorization = `Bearer ${token}`; + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } } const telegramInitData = getTelegramInitData(); @@ -111,6 +134,11 @@ export interface ChannelSubscriptionError { channel_link?: string; } +export interface BlacklistedError { + code: 'blacklisted'; + message: string; +} + export function isMaintenanceError( error: unknown, ): error is { response: { status: 503; data: { detail: MaintenanceError } } } { @@ -130,6 +158,14 @@ export function isChannelSubscriptionError( ); } +export function isBlacklistedError( + error: unknown, +): error is { response: { status: 403; data: { detail: BlacklistedError } } } { + if (!error || typeof error !== 'object') return false; + const err = error as AxiosError<{ detail: BlacklistedError }>; + return err.response?.status === 403 && err.response?.data?.detail?.code === 'blacklisted'; +} + // Response interceptor - handle 401, 503 (maintenance), 403 (channel subscription) apiClient.interceptors.response.use( (response) => response, @@ -156,6 +192,15 @@ apiClient.interceptors.response.use( return Promise.reject(error); } + // Handle blacklisted user (403) + if (isBlacklistedError(error)) { + const detail = (error.response?.data as { detail: BlacklistedError }).detail; + useBlockingStore.getState().setBlacklisted({ + message: detail.message, + }); + return Promise.reject(error); + } + // Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала) if (error.response?.status === 401 && !originalRequest._retry) { // Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента diff --git a/src/api/contests.ts b/src/api/contests.ts index 75f7054..5e3b2f1 100644 --- a/src/api/contests.ts +++ b/src/api/contests.ts @@ -13,6 +13,7 @@ export interface ContestInfo { export interface ContestGameData { round_id: number; game_type: string; + // Dynamic game data from backend - shape depends on game_type // eslint-disable-next-line @typescript-eslint/no-explicit-any game_data: Record; instructions: string; diff --git a/src/api/index.ts b/src/api/index.ts index 404dcce..ffe0600 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -10,3 +10,22 @@ export { promoApi } from './promo'; export { notificationsApi } from './notifications'; export { infoApi } from './info'; export { adminSettingsApi } from './adminSettings'; +export { adminApi, statsApi } from './admin'; +export { adminAppsApi } from './adminApps'; +export { adminBroadcastsApi } from './adminBroadcasts'; +export { adminEmailTemplatesApi } from './adminEmailTemplates'; +export { adminPaymentMethodsApi } from './adminPaymentMethods'; +export { adminPaymentsApi } from './adminPayments'; +export { adminRemnawaveApi } from './adminRemnawave'; +export { adminUsersApi } from './adminUsers'; +export { banSystemApi } from './banSystem'; +export { brandingApi } from './branding'; +export { campaignsApi } from './campaigns'; +export { currencyApi } from './currency'; +export { promocodesApi } from './promocodes'; +export { promoOffersApi } from './promoOffers'; +export { serversApi } from './servers'; +export { tariffsApi } from './tariffs'; +export { themeColorsApi } from './themeColors'; +export { ticketNotificationsApi } from './ticketNotifications'; +export { wheelApi, adminWheelApi } from './wheel'; diff --git a/src/api/miniapp.ts b/src/api/miniapp.ts deleted file mode 100644 index 6346b33..0000000 --- a/src/api/miniapp.ts +++ /dev/null @@ -1,44 +0,0 @@ -import axios, { AxiosError } from 'axios'; - -export interface MiniappCreatePaymentPayload { - initData: string; - method: string; - amountKopeks?: number | null; - option?: string | null; -} - -export interface MiniappCreatePaymentResponse { - payment_url: string; - amount_kopeks?: number; - extra?: Record; -} - -export const miniappApi = { - // Create payment inside Telegram Mini App (same flow as miniapp/index.html) - createPayment: async ( - payload: MiniappCreatePaymentPayload, - ): Promise => { - try { - const response = await axios.post( - '/miniapp/payments/create', - { - initData: payload.initData || '', - method: payload.method, - amountKopeks: payload.amountKopeks ?? null, - option: payload.option ?? null, - }, - { - headers: { 'Content-Type': 'application/json' }, - }, - ); - return response.data; - } catch (error) { - const axiosError = error as AxiosError<{ detail?: string; message?: string }>; - const message = - axiosError.response?.data?.detail || - axiosError.response?.data?.message || - 'Failed to create payment'; - throw new Error(message); - } - }, -}; diff --git a/src/api/promo.ts b/src/api/promo.ts index 459f141..1610917 100644 --- a/src/api/promo.ts +++ b/src/api/promo.ts @@ -9,6 +9,7 @@ export interface PromoOffer { is_active: boolean; is_claimed: boolean; claimed_at: string | null; + // Dynamic extra data from backend // eslint-disable-next-line @typescript-eslint/no-explicit-any extra_data: Record | null; } diff --git a/src/api/promoOffers.ts b/src/api/promoOffers.ts index fdc656a..533cfdd 100644 --- a/src/api/promoOffers.ts +++ b/src/api/promoOffers.ts @@ -31,6 +31,7 @@ export interface PromoOffer { claimed_at: string | null; is_active: boolean; effect_type: string; + // Dynamic extra data from backend - shape depends on effect_type // eslint-disable-next-line @typescript-eslint/no-explicit-any extra_data: Record; created_at: string; diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 868d517..99fe63d 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -91,6 +91,11 @@ export const subscriptionApi = { can_add?: number; days_left?: number; base_device_price_kopeks?: number; + // Discount fields (from promo group) + original_price_per_device_kopeks?: number; + base_total_price_kopeks?: number; + discount_percent?: number; + discount_kopeks?: number; }> => { const response = await apiClient.get('/cabinet/subscription/devices/price', { params: { devices }, @@ -355,6 +360,10 @@ export const subscriptionApi = { missing_amount_kopeks: number; missing_amount_label: string; is_upgrade: boolean; + // Discount fields (from promo group) + base_upgrade_cost_kopeks?: number; + discount_percent?: number; + discount_kopeks?: number; }> => { const response = await apiClient.post('/cabinet/subscription/tariff/switch/preview', { tariff_id: tariffId, diff --git a/src/api/tariffs.ts b/src/api/tariffs.ts index 47bd121..bd8ba11 100644 --- a/src/api/tariffs.ts +++ b/src/api/tariffs.ts @@ -231,6 +231,11 @@ export const tariffsApi = { return response.data; }, + // Update tariff display order + updateOrder: async (tariffIds: number[]): Promise => { + await apiClient.put('/cabinet/admin/tariffs/order', { tariff_ids: tariffIds }); + }, + // Get available servers for selection getAvailableServers: async (): Promise => { const response = await apiClient.get('/cabinet/admin/tariffs/available-servers'); diff --git a/src/components/ChangeEmailModal.tsx b/src/components/ChangeEmailModal.tsx index 5d8f081..fe49917 100644 --- a/src/components/ChangeEmailModal.tsx +++ b/src/components/ChangeEmailModal.tsx @@ -3,9 +3,10 @@ import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { authApi } from '../api/auth'; +import { isValidEmail } from '../utils/validation'; +import { UI } from '../config/constants'; import { useAuthStore } from '../store/auth'; -import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; -import { useBackButton } from '@/platform'; +import { useTelegramSDK } from '../hooks/useTelegramSDK'; // Icons const CloseIcon = () => ( @@ -50,13 +51,13 @@ function useIsMobile() { return isMobile; } -const RESEND_COOLDOWN = 60; // seconds +const RESEND_COOLDOWN = UI.RESEND_COOLDOWN_SEC; export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailModalProps) { const { t } = useTranslation(); const queryClient = useQueryClient(); const { setUser } = useAuthStore(); - const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp(); + const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramSDK(); const isMobileScreen = useIsMobile(); const emailInputRef = useRef(null); @@ -88,9 +89,6 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM return () => document.removeEventListener('keydown', handleKeyDown); }, [handleClose]); - // Telegram back button - using platform hook - useBackButton(handleClose); - // Scroll lock useEffect(() => { const scrollY = window.scrollY; @@ -181,18 +179,13 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM }, }); - const validateEmail = (email: string): boolean => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email.trim()); - }; - const handleSendCode = () => { setError(null); if (!newEmail.trim()) { setError(t('profile.emailRequired')); return; } - if (!validateEmail(newEmail)) { + if (!isValidEmail(newEmail.trim())) { setError(t('profile.invalidEmail')); return; } 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/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..aa4e47a --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,93 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +interface ErrorBoundaryProps { + children: ReactNode; + level?: 'app' | 'page' | 'widget'; + onReset?: () => void; +} + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('[ErrorBoundary]', error, errorInfo); + } + + handleReset = () => { + this.setState({ hasError: false, error: null }); + this.props.onReset?.(); + }; + + render() { + if (!this.state.hasError) { + return this.props.children; + } + + const { level = 'page' } = this.props; + + if (level === 'app') { + return ( +
+
+
⚠️
+

Something went wrong

+

+ An unexpected error occurred. Please try reloading the page. +

+ +
+
+ ); + } + + if (level === 'widget') { + return ( +
+

Failed to load this section

+ +
+ ); + } + + // level === 'page' (default) + return ( +
+
+
⚠️
+

Something went wrong

+

+ {this.state.error?.message || 'An unexpected error occurred'} +

+ +
+
+ ); + } +} diff --git a/src/components/InsufficientBalancePrompt.tsx b/src/components/InsufficientBalancePrompt.tsx index d7ceeda..75ebae1 100644 --- a/src/components/InsufficientBalancePrompt.tsx +++ b/src/components/InsufficientBalancePrompt.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useCurrency } from '../hooks/useCurrency'; diff --git a/src/components/PromoDiscountBadge.tsx b/src/components/PromoDiscountBadge.tsx deleted file mode 100644 index 6bc227d..0000000 --- a/src/components/PromoDiscountBadge.tsx +++ /dev/null @@ -1,385 +0,0 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from 'react-router-dom'; -import { createPortal } from 'react-dom'; -import { useTranslation } from 'react-i18next'; -import { promoApi } from '../api/promo'; - -const SparklesIcon = () => ( - - - -); - -const ClockIcon = () => ( - - - -); - -const XCircleIcon = () => ( - - - -); - -const WarningIcon = () => ( - - - -); - -const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => { - const now = new Date(); - // Ensure UTC parsing - if no timezone specified, assume UTC - let expires: Date; - if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) { - expires = new Date(expiresAt); - } else { - // No timezone - treat as UTC - expires = new Date(expiresAt + 'Z'); - } - const diffMs = expires.getTime() - now.getTime(); - - if (diffMs <= 0) return ''; - - const hours = Math.floor(diffMs / (1000 * 60 * 60)); - const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); - - if (hours > 24) { - const days = Math.floor(hours / 24); - return `${days}${t('promo.time.days')}`; - } - if (hours > 0) { - return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`; - } - return `${minutes}${t('promo.time.minutes')}`; -}; - -// ------- Confirmation Modal ------- - -interface DeactivateConfirmModalProps { - isOpen: boolean; - discountPercent: number; - isDeactivating: boolean; - onConfirm: () => void; - onCancel: () => void; -} - -function DeactivateConfirmModal({ - isOpen, - discountPercent, - isDeactivating, - onConfirm, - onCancel, -}: DeactivateConfirmModalProps) { - const { t } = useTranslation(); - - // Escape key to close - useEffect(() => { - if (!isOpen) return; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' && !isDeactivating) { - e.preventDefault(); - onCancel(); - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [isOpen, isDeactivating, onCancel]); - - // Scroll lock - useEffect(() => { - if (!isOpen) return; - - document.body.style.overflow = 'hidden'; - return () => { - document.body.style.overflow = ''; - }; - }, [isOpen]); - - if (!isOpen) return null; - - const modalContent = ( -
- {/* Backdrop */} -
- - {/* Modal */} -
e.stopPropagation()} - > - {/* Warning header */} -
-
- -
-

- {t('promo.deactivate.confirmTitle')} -

-

- {t('promo.deactivate.confirmDescription', { percent: discountPercent })} -

-
- - {/* Warning text */} -
-
-

{t('promo.deactivate.warning')}

-
-
- - {/* Action buttons */} -
- - -
-
-
- ); - - if (typeof document !== 'undefined') { - return createPortal(modalContent, document.body); - } - return modalContent; -} - -// ------- Main Component ------- - -export default function PromoDiscountBadge() { - const { t } = useTranslation(); - const navigate = useNavigate(); - const queryClient = useQueryClient(); - const [isOpen, setIsOpen] = useState(false); - const [showConfirmModal, setShowConfirmModal] = useState(false); - const [deactivateError, setDeactivateError] = useState(null); - const dropdownRef = useRef(null); - - const { data: activeDiscount } = useQuery({ - queryKey: ['active-discount'], - queryFn: promoApi.getActiveDiscount, - staleTime: 30000, - refetchInterval: 60000, // Refresh every minute - }); - - const deactivateMutation = useMutation({ - mutationFn: async () => { - const source = activeDiscount?.source; - if (source && source.startsWith('promocode:')) { - await promoApi.deactivateDiscount(); - } else { - await promoApi.clearActiveDiscount(); - } - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['active-discount'] }); - queryClient.invalidateQueries({ queryKey: ['promo-offers'] }); - queryClient.invalidateQueries({ queryKey: ['subscription'] }); - queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); - setShowConfirmModal(false); - setIsOpen(false); - setDeactivateError(null); - }, - onError: (error: unknown) => { - const axiosErr = error as { response?: { data?: { detail?: string } } }; - setDeactivateError(axiosErr.response?.data?.detail || t('promo.deactivate.error')); - }, - }); - - const handleCloseConfirmModal = useCallback(() => { - if (!deactivateMutation.isPending) { - setShowConfirmModal(false); - setDeactivateError(null); - } - }, [deactivateMutation.isPending]); - - const handleConfirmDeactivate = useCallback(() => { - setDeactivateError(null); - deactivateMutation.mutate(); - }, [deactivateMutation]); - - // Close dropdown on click outside - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsOpen(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - // Don't render if no active discount - if (!activeDiscount || !activeDiscount.is_active || !activeDiscount.discount_percent) { - return null; - } - - const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null; - - const handleClick = () => { - setIsOpen(!isOpen); - }; - - const handleGoToSubscription = () => { - setIsOpen(false); - navigate('/subscription'); - }; - - const handleDeactivateClick = () => { - setDeactivateError(null); - setShowConfirmModal(true); - }; - - return ( - <> -
- {/* Badge button */} - - - {/* Dropdown - mobile: fixed centered, desktop: absolute right */} - {isOpen && ( - <> - {/* Mobile backdrop */} -
setIsOpen(false)} - /> - -
- {/* Header */} -
-
-
- -
-
-
{t('promo.discountActive')}
-
- -{activeDiscount.discount_percent}% -
-
-
-
- - {/* Content */} -
-

{t('promo.discountDescription')}

- - {/* Time remaining */} - {timeLeft && ( -
- - - {t('promo.expiresIn')}:{' '} - {timeLeft} - -
- )} - - {/* Deactivation error */} - {deactivateError && ( -
- {deactivateError} -
- )} - - {/* CTA Button */} - - - {/* Deactivate Button */} - -
-
- - )} -
- - {/* Deactivation Confirmation Modal */} - - - ); -} diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx index f14ee55..b0a3ac4 100644 --- a/src/components/PromoOffersSection.tsx +++ b/src/components/PromoOffersSection.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { promoApi, PromoOffer } from '../api/promo'; import { ClockIcon, CheckIcon } from './icons'; import { usePlatform } from '@/platform/hooks/usePlatform'; diff --git a/src/components/SuccessNotificationModal.tsx b/src/components/SuccessNotificationModal.tsx index dcb4c45..a97b49a 100644 --- a/src/components/SuccessNotificationModal.tsx +++ b/src/components/SuccessNotificationModal.tsx @@ -6,11 +6,11 @@ import { useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { useSuccessNotification } from '../store/successNotification'; import { useCurrency } from '../hooks/useCurrency'; -import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; -import { useBackButton, useHaptic } from '@/platform'; +import { useTelegramSDK } from '../hooks/useTelegramSDK'; +import { useHaptic } from '@/platform'; // Icons const CheckCircleIcon = () => ( @@ -80,7 +80,7 @@ export default function SuccessNotificationModal() { const navigate = useNavigate(); const { isOpen, data, hide } = useSuccessNotification(); const { formatAmount, currencySymbol } = useCurrency(); - const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramWebApp(); + const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramSDK(); const haptic = useHaptic(); const safeBottom = isTelegramWebApp @@ -106,9 +106,6 @@ export default function SuccessNotificationModal() { return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, handleClose]); - // Telegram back button - using platform hook - useBackButton(isOpen ? handleClose : null); - // Haptic feedback on open useEffect(() => { if (isOpen) { diff --git a/src/components/ThemeBentoPicker.tsx b/src/components/ThemeBentoPicker.tsx deleted file mode 100644 index dadc957..0000000 --- a/src/components/ThemeBentoPicker.tsx +++ /dev/null @@ -1,579 +0,0 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets'; -import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion'; -import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme'; -import { applyThemeColors } from '../hooks/useThemeColors'; - -interface ThemeBentoPickerProps { - currentColors: ThemeColors; - onColorsChange: (colors: ThemeColors) => void; - onSave: () => void; - isSaving: boolean; -} - -const CheckIcon = () => ( - - - -); - -const ChevronDownIcon = () => ( - - - -); - -const MoonIcon = () => ( - - - -); - -const SunIcon = () => ( - - - -); - -const StatusIcon = () => ( - - - -); - -const PaletteIcon = () => ( - - - -); - -function PresetCard({ - preset, - isSelected, - onClick, -}: { - preset: ColorPreset; - isSelected: boolean; - onClick: () => void; -}) { - const { i18n } = useTranslation(); - const isRu = i18n.language === 'ru'; - - return ( - - ); -} - -function HSLSlider({ - label, - value, - onChange, - max, - gradient, - suffix = '', -}: { - label: string; - value: number; - onChange: (value: number) => void; - max: number; - gradient: string; - suffix?: string; -}) { - return ( -
-
- - - {value} - {suffix} - -
- onChange(parseInt(e.target.value))} - className="h-2.5 w-full cursor-pointer appearance-none rounded-full" - style={{ background: gradient }} - /> -
- ); -} - -function CompactColorInput({ - label, - value, - onChange, -}: { - label: string; - value: string; - onChange: (color: string) => void; -}) { - const [localValue, setLocalValue] = useState(value); - const [isEditing, setIsEditing] = useState(false); - - useEffect(() => { - setLocalValue(value); - }, [value]); - - const handleChange = (newValue: string) => { - let formatted = newValue.toUpperCase(); - if (!formatted.startsWith('#')) { - formatted = '#' + formatted; - } - setLocalValue(formatted); - if (isValidHex(formatted)) { - onChange(formatted); - } - }; - - const handleBlur = () => { - setIsEditing(false); - if (!isValidHex(localValue)) { - setLocalValue(value); - } - }; - - return ( -
- - )} -
-
- ); -} - -function CollapsibleSection({ - title, - icon, - isOpen, - onToggle, - children, - badge, -}: { - title: string; - icon: React.ReactNode; - isOpen: boolean; - onToggle: () => void; - children: React.ReactNode; - badge?: string; -}) { - return ( -
- - -
-
-
{children}
-
-
-
- ); -} - -export function ThemeBentoPicker({ - currentColors, - onColorsChange, - onSave, - isSaving, -}: ThemeBentoPickerProps) { - const { t } = useTranslation(); - - const [hsl, setHsl] = useState(() => hexToHsl(currentColors.accent)); - const [hexInput, setHexInput] = useState(currentColors.accent); - const [hasChanges, setHasChanges] = useState(false); - - const [isPresetsOpen, setIsPresetsOpen] = useState(false); - const [isAccentOpen, setIsAccentOpen] = useState(false); - const [isDarkOpen, setIsDarkOpen] = useState(false); - const [isLightOpen, setIsLightOpen] = useState(false); - const [isStatusOpen, setIsStatusOpen] = useState(false); - - const selectedPresetId = useMemo(() => { - const match = COLOR_PRESETS.find( - (p) => - p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() && - p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() && - p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase(), - ); - return match?.id ?? null; - }, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]); - - useEffect(() => { - setHsl(hexToHsl(currentColors.accent)); - setHexInput(currentColors.accent); - }, [currentColors.accent]); - - const updateColor = useCallback( - (key: keyof ThemeColors, value: string) => { - const newColors = { ...currentColors, [key]: value }; - onColorsChange(newColors); - applyThemeColors(newColors); - setHasChanges(true); - }, - [currentColors, onColorsChange], - ); - - const updateAccentFromHsl = useCallback( - (newHsl: HSLColor) => { - setHsl(newHsl); - const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l); - setHexInput(newHex); - updateColor('accent', newHex); - }, - [updateColor], - ); - - const handleHexInputChange = (value: string) => { - setHexInput(value); - if (isValidHex(value)) { - const newHsl = hexToHsl(value); - setHsl(newHsl); - updateColor('accent', value); - } - }; - - const handlePresetSelect = (preset: ColorPreset) => { - onColorsChange(preset.colors); - applyThemeColors(preset.colors); - setHasChanges(true); - }; - - const hueGradient = useMemo(() => { - return `linear-gradient(to right, - hsl(0, ${hsl.s}%, ${hsl.l}%), - hsl(60, ${hsl.s}%, ${hsl.l}%), - hsl(120, ${hsl.s}%, ${hsl.l}%), - hsl(180, ${hsl.s}%, ${hsl.l}%), - hsl(240, ${hsl.s}%, ${hsl.l}%), - hsl(300, ${hsl.s}%, ${hsl.l}%), - hsl(360, ${hsl.s}%, ${hsl.l}%) - )`; - }, [hsl.s, hsl.l]); - - const saturationGradient = useMemo(() => { - return `linear-gradient(to right, - hsl(${hsl.h}, 0%, ${hsl.l}%), - hsl(${hsl.h}, 100%, ${hsl.l}%) - )`; - }, [hsl.h, hsl.l]); - - const lightnessGradient = useMemo(() => { - return `linear-gradient(to right, - hsl(${hsl.h}, ${hsl.s}%, 0%), - hsl(${hsl.h}, ${hsl.s}%, 50%), - hsl(${hsl.h}, ${hsl.s}%, 100%) - )`; - }, [hsl.h, hsl.s]); - - return ( -
- } - badge={`${COLOR_PRESETS.length}`} - isOpen={isPresetsOpen} - onToggle={() => setIsPresetsOpen(!isPresetsOpen)} - > -
- {COLOR_PRESETS.map((preset, index) => ( -
- handlePresetSelect(preset)} - /> -
- ))} -
-
- -
-

- {t('admin.theme.customizeColors', 'Customize Colors')} -

- - } - badge={hexInput.toUpperCase()} - isOpen={isAccentOpen} - onToggle={() => setIsAccentOpen(!isAccentOpen)} - > -
-
-
-
- {hexInput.toUpperCase()} -
-
- - updateAccentFromHsl({ ...hsl, h })} - max={360} - gradient={hueGradient} - suffix="°" - /> - - updateAccentFromHsl({ ...hsl, s })} - max={100} - gradient={saturationGradient} - suffix="%" - /> - - updateAccentFromHsl({ ...hsl, l })} - max={100} - gradient={lightnessGradient} - suffix="%" - /> - -
- - handleHexInputChange(e.target.value)} - placeholder="#3b82f6" - maxLength={7} - className="input w-full font-mono text-sm uppercase" - /> -
-
- - - } - isOpen={isDarkOpen} - onToggle={() => setIsDarkOpen(!isDarkOpen)} - > -
- updateColor('darkBackground', c)} - /> - updateColor('darkSurface', c)} - /> - updateColor('darkText', c)} - /> - updateColor('darkTextSecondary', c)} - /> -
-
- - } - isOpen={isLightOpen} - onToggle={() => setIsLightOpen(!isLightOpen)} - > -
- updateColor('lightBackground', c)} - /> - updateColor('lightSurface', c)} - /> - updateColor('lightText', c)} - /> - updateColor('lightTextSecondary', c)} - /> -
-
- - } - isOpen={isStatusOpen} - onToggle={() => setIsStatusOpen(!isStatusOpen)} - > -
- updateColor('success', c)} - /> - updateColor('warning', c)} - /> - updateColor('error', c)} - /> -
-
-
- -
-

- {t('theme.preview', 'Preview')} -

-
- - - {t('theme.success', 'Success')} - {t('theme.warning', 'Warning')} - {t('theme.error', 'Error')} -
-
- - {hasChanges && ( -
- -
- )} -
- ); -} diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx index c44a6f7..693f58d 100644 --- a/src/components/TicketNotificationBell.tsx +++ b/src/components/TicketNotificationBell.tsx @@ -1,12 +1,12 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { ticketNotificationsApi } from '../api/ticketNotifications'; import { useAuthStore } from '../store/auth'; import { useToast } from './Toast'; import { useWebSocket, WSMessage } from '../hooks/useWebSocket'; -import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; +import { useTelegramSDK } from '../hooks/useTelegramSDK'; import type { TicketNotification } from '../types'; const BellIcon = () => ( @@ -37,7 +37,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi const { showToast } = useToast(); const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); - const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp(); + const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramSDK(); // Calculate dropdown top position (account for fullscreen safe area + TG buttons) const dropdownTop = isFullscreen diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 63864ed..0b85413 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -27,6 +27,8 @@ interface ToastContextType { const ToastContext = createContext(null); +const MAX_VISIBLE = 3; + // eslint-disable-next-line react-refresh/only-export-components export function useToast() { const context = useContext(ToastContext); @@ -40,22 +42,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { const [toasts, setToasts] = useState([]); const timersRef = useRef>>(new Map()); - const showToast = useCallback((options: ToastOptions) => { - const id = Date.now() + Math.random(); // Avoid ID collision - const toast: Toast = { id, duration: 5000, type: 'info', ...options }; - - setToasts((prev) => [...prev, toast]); - - const timer = setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== id)); - timersRef.current.delete(id); - }, toast.duration); - - timersRef.current.set(id, timer); - }, []); - const removeToast = useCallback((id: number) => { - // Clear timer when manually removing const timer = timersRef.current.get(id); if (timer) { clearTimeout(timer); @@ -64,6 +51,37 @@ export function ToastProvider({ children }: { children: ReactNode }) { setToasts((prev) => prev.filter((t) => t.id !== id)); }, []); + const showToast = useCallback( + (options: ToastOptions) => { + const id = Date.now() + Math.random(); + const toast: Toast = { id, duration: 5000, type: 'info', ...options }; + + setToasts((prev) => { + const next = [...prev, toast]; + // Evict oldest toasts beyond the limit + if (next.length > MAX_VISIBLE) { + const evicted = next.slice(0, next.length - MAX_VISIBLE); + for (const old of evicted) { + const timer = timersRef.current.get(old.id); + if (timer) { + clearTimeout(timer); + timersRef.current.delete(old.id); + } + } + return next.slice(-MAX_VISIBLE); + } + return next; + }); + + const timer = setTimeout(() => { + removeToast(id); + }, toast.duration); + + timersRef.current.set(id, timer); + }, + [removeToast], + ); + // Cleanup all timers on unmount useEffect(() => { const timers = timersRef.current; @@ -77,8 +95,8 @@ export function ToastProvider({ children }: { children: ReactNode }) { {children} - {/* Toast Container */} -
+ {/* Toast Container — safe area aware, adaptive width */} +
{toasts.map((toast) => ( removeToast(toast.id)} /> ))} @@ -89,40 +107,34 @@ export function ToastProvider({ children }: { children: ReactNode }) { function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { const handleClick = () => { - if (toast.onClick) { - toast.onClick(); - onClose(); - } + toast.onClick?.(); + onClose(); }; const typeStyles = { success: { - bg: 'bg-dark-800', - accent: 'bg-gradient-to-r from-success-500/50 to-transparent', - border: 'border-success-500/70', + border: 'border-l-success-500', icon: 'text-success-400', - iconBg: 'bg-success-500/30', + iconBg: 'bg-success-500/20', + progress: 'bg-success-400', }, error: { - bg: 'bg-dark-800', - accent: 'bg-gradient-to-r from-error-500/50 to-transparent', - border: 'border-error-500/70', + border: 'border-l-error-500', icon: 'text-error-400', - iconBg: 'bg-error-500/30', + iconBg: 'bg-error-500/20', + progress: 'bg-error-400', }, warning: { - bg: 'bg-dark-800', - accent: 'bg-gradient-to-r from-warning-500/50 to-transparent', - border: 'border-warning-500/70', + border: 'border-l-warning-500', icon: 'text-warning-400', - iconBg: 'bg-warning-500/30', + iconBg: 'bg-warning-500/20', + progress: 'bg-warning-400', }, info: { - bg: 'bg-dark-800', - accent: 'bg-gradient-to-r from-accent-500/50 to-transparent', - border: 'border-accent-500/70', + border: 'border-l-accent-500', icon: 'text-accent-400', - iconBg: 'bg-accent-500/30', + iconBg: 'bg-accent-500/20', + progress: 'bg-accent-400', }, }; @@ -185,17 +197,14 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { return (
- {/* Color accent */} -
-
{/* Icon */}
{toast.icon || defaultIcons[toast.type || 'info']}
@@ -207,31 +216,12 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { )}

{toast.message}

- - {/* Close button */} -
{/* Progress bar */}
{children}
; -} diff --git a/src/components/admin/BrandingTab.tsx b/src/components/admin/BrandingTab.tsx index bcdd233..915fa29 100644 --- a/src/components/admin/BrandingTab.tsx +++ b/src/components/admin/BrandingTab.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { brandingApi, setCachedBranding } from '../../api/branding'; import { setCachedAnimationEnabled } from '../AnimatedBackground'; -import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp'; +import { setCachedFullscreenEnabled } from '../../hooks/useTelegramSDK'; import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons'; import { Toggle } from './Toggle'; diff --git a/src/components/admin/SettingsSidebar.tsx b/src/components/admin/SettingsSidebar.tsx deleted file mode 100644 index 5236c00..0000000 --- a/src/components/admin/SettingsSidebar.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { AdminBackButton, StarIcon, CloseIcon, MENU_SECTIONS } from './index'; - -interface SettingsSidebarProps { - activeSection: string; - setActiveSection: (section: string) => void; - mobileMenuOpen: boolean; - setMobileMenuOpen: (open: boolean) => void; - favoritesCount: number; -} - -export function SettingsSidebar({ - activeSection, - setActiveSection, - mobileMenuOpen, - setMobileMenuOpen, - favoritesCount, -}: SettingsSidebarProps) { - const { t } = useTranslation(); - - return ( - - ); -} diff --git a/src/components/admin/ThemeTab.tsx b/src/components/admin/ThemeTab.tsx index e31a59a..da9f2f3 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,88 @@ export function ThemeTab() { queryFn: themeColorsApi.getEnabledThemes, }); + // Local draft state + const [draftColors, setDraftColors] = useState(DEFAULT_THEME_COLORS); + const savedColorsRef = useRef(DEFAULT_THEME_COLORS); + const draftColorsRef = useRef(draftColors); + draftColorsRef.current = draftColors; + + // Sync server data into draft and saved snapshot when it arrives + useEffect(() => { + if (serverColors) { + const colors: ThemeColors = { + accent: serverColors.accent, + darkBackground: serverColors.darkBackground, + darkSurface: serverColors.darkSurface, + darkText: serverColors.darkText, + darkTextSecondary: serverColors.darkTextSecondary, + lightBackground: serverColors.lightBackground, + lightSurface: serverColors.lightSurface, + lightText: serverColors.lightText, + lightTextSecondary: serverColors.lightTextSecondary, + success: serverColors.success, + warning: serverColors.warning, + error: serverColors.error, + }; + // Only sync if saved snapshot matches current draft (no unsaved changes) + if ( + colorsEqual(savedColorsRef.current, draftColorsRef.current) || + colorsEqual(savedColorsRef.current, DEFAULT_THEME_COLORS) + ) { + setDraftColors(colors); + } + savedColorsRef.current = colors; + } + }, [serverColors]); + + const hasUnsavedChanges = !colorsEqual(draftColors, savedColorsRef.current); + // Mutations const updateColorsMutation = useMutation({ mutationFn: themeColorsApi.updateColors, onSuccess: (data) => { - applyThemeColors(data); - queryClient.invalidateQueries({ queryKey: ['theme-colors'] }); + const colors: ThemeColors = { + accent: data.accent, + darkBackground: data.darkBackground, + darkSurface: data.darkSurface, + darkText: data.darkText, + darkTextSecondary: data.darkTextSecondary, + lightBackground: data.lightBackground, + lightSurface: data.lightSurface, + lightText: data.lightText, + lightTextSecondary: data.lightTextSecondary, + success: data.success, + warning: data.warning, + error: data.error, + }; + savedColorsRef.current = colors; + setDraftColors(colors); + applyThemeColors(colors); + queryClient.setQueryData(['theme-colors'], data); }, }); const resetColorsMutation = useMutation({ mutationFn: themeColorsApi.resetColors, onSuccess: (data) => { - applyThemeColors(data); - queryClient.invalidateQueries({ queryKey: ['theme-colors'] }); + const colors: ThemeColors = { + accent: data.accent, + darkBackground: data.darkBackground, + darkSurface: data.darkSurface, + darkText: data.darkText, + darkTextSecondary: data.darkTextSecondary, + lightBackground: data.lightBackground, + lightSurface: data.lightSurface, + lightText: data.lightText, + lightTextSecondary: data.lightTextSecondary, + success: data.success, + warning: data.warning, + error: data.error, + }; + savedColorsRef.current = colors; + setDraftColors(colors); + applyThemeColors(colors); + queryClient.setQueryData(['theme-colors'], data); }, }); @@ -64,6 +149,42 @@ export function ThemeTab() { }, }); + // Update a single color in the draft and apply preview instantly + const updateDraftColor = useCallback( + (key: keyof ThemeColors, value: string) => { + setDraftColors((prev) => { + const next = { ...prev, [key]: value }; + applyThemeColors(next); + queryClient.setQueryData(['theme-colors'], next); + return next; + }); + }, + [queryClient], + ); + + // Apply a full preset and auto-save to server + const applyPreset = useCallback( + (colors: Partial) => { + setDraftColors((prev) => { + const next = { ...prev, ...colors }; + applyThemeColors(next); + queryClient.setQueryData(['theme-colors'], next); + // Auto-save preset to server so it persists across navigation + updateColorsMutation.mutate(next); + return next; + }); + }, + [queryClient, updateColorsMutation], + ); + + // Cancel: revert draft to saved + const handleCancel = useCallback(() => { + const saved = savedColorsRef.current; + setDraftColors(saved); + applyThemeColors(saved); + queryClient.setQueryData(['theme-colors'], saved); + }, [queryClient]); + return (
{/* Theme toggles */} @@ -130,8 +251,7 @@ export function ThemeTab() { {THEME_PRESETS.map((preset) => (
@@ -203,27 +322,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 +351,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,36 +380,59 @@ 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 && ( + <> + + + + )} +
)}
+ + {/* Reset all colors */} +
+ +
); } diff --git a/src/components/admin/index.ts b/src/components/admin/index.ts index 3bbeafd..4fc1815 100644 --- a/src/components/admin/index.ts +++ b/src/components/admin/index.ts @@ -1,6 +1,5 @@ // Components export * from './AdminBackButton'; -export * from './AdminLayout'; export * from './icons'; export * from './Toggle'; export * from './SettingInput'; @@ -10,7 +9,6 @@ export * from './BrandingTab'; export * from './ThemeTab'; export * from './FavoritesTab'; export * from './SettingsTab'; -export * from './SettingsSidebar'; export * from './SettingsMobileTabs'; export * from './SettingsSearch'; diff --git a/src/components/blocking/BlacklistedScreen.tsx b/src/components/blocking/BlacklistedScreen.tsx new file mode 100644 index 0000000..1cbedb8 --- /dev/null +++ b/src/components/blocking/BlacklistedScreen.tsx @@ -0,0 +1,48 @@ +import { useTranslation } from 'react-i18next'; +import { useBlockingStore } from '../../store/blocking'; + +export default function BlacklistedScreen() { + const { t } = useTranslation(); + const { blacklistedInfo } = useBlockingStore(); + + return ( +
+
+ {/* Icon */} +
+
+ + + +
+
+ + {/* Title */} +

{t('blocking.blacklisted.title')}

+ + {/* Message */} +

{t('blocking.blacklisted.defaultMessage')}

+ + {/* Reason */} + {blacklistedInfo?.message && ( +
+

{t('blocking.blacklisted.reason')}:

+

{blacklistedInfo.message}

+
+ )} + +

{t('blocking.blacklisted.contactSupport')}

+
+
+ ); +} diff --git a/src/components/blocking/index.ts b/src/components/blocking/index.ts index ca8e854..d72b87d 100644 --- a/src/components/blocking/index.ts +++ b/src/components/blocking/index.ts @@ -1,2 +1,3 @@ export { default as MaintenanceScreen } from './MaintenanceScreen'; export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen'; +export { default as BlacklistedScreen } from './BlacklistedScreen'; diff --git a/src/components/common/PageLoader.tsx b/src/components/common/PageLoader.tsx index 4f1ade8..125f3a9 100644 --- a/src/components/common/PageLoader.tsx +++ b/src/components/common/PageLoader.tsx @@ -6,7 +6,7 @@ export default function PageLoader({ variant = 'dark' }: PageLoaderProps) { const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500'; return ( -
+
diff --git a/src/components/connection/InstallationGuide.tsx b/src/components/connection/InstallationGuide.tsx new file mode 100644 index 0000000..a9905bc --- /dev/null +++ b/src/components/connection/InstallationGuide.tsx @@ -0,0 +1,327 @@ +import { useState, useMemo, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import DOMPurify from 'dompurify'; +import type { + AppConfig, + LocalizedText, + RemnawaveAppClient, + RemnawavePlatformData, + RemnawaveButtonClient, +} from '@/types'; +import { useTheme } from '@/hooks/useTheme'; +import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks'; +import type { BlockRendererProps } from './blocks'; + +const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']; + +function detectPlatform(): string | null { + if (typeof window === 'undefined' || !navigator?.userAgent) return null; + const ua = navigator.userAgent.toLowerCase(); + if (/iphone|ipad|ipod/.test(ua)) return 'ios'; + if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android'; + if (/macintosh|mac os x/.test(ua)) return 'macos'; + if (/windows/.test(ua)) return 'windows'; + if (/linux/.test(ua)) return 'linux'; + return null; +} + +const RENDERERS: Record> = { + cards: CardsBlock, + timeline: TimelineBlock, + accordion: AccordionBlock, + minimal: MinimalBlock, +}; + +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 { isLight } = useTheme(); + + const detectedPlatform = useMemo(() => detectPlatform(), []); + const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; + + const [activePlatformKey, setActivePlatformKey] = useState(null); + const [selectedApp, setSelectedApp] = useState(null); + + // --- Helpers --- + + const getLocalizedText = useCallback( + (text: LocalizedText | undefined): string => { + if (!text) return ''; + const lang = i18n.language || 'en'; + return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || ''; + }, + [i18n.language], + ); + + const getBaseTranslation = useCallback( + (key: string, i18nKey: string): string => { + const bt = appConfig.baseTranslations; + if (bt && key in bt) { + const text = getLocalizedText(bt[key as keyof typeof bt] as LocalizedText); + if (text) return text; + } + return t(i18nKey); + }, + [appConfig.baseTranslations, getLocalizedText, t], + ); + + const getSvgHtml = useCallback( + (svgKey: string | undefined): string => { + if (!svgKey || !appConfig.svgLibrary?.[svgKey]) return ''; + const entry = appConfig.svgLibrary[svgKey]; + const raw = typeof entry === 'string' ? entry : entry.svgString; + if (!raw) return ''; + return DOMPurify.sanitize(raw, { USE_PROFILES: { svg: true, svgFilters: true } }); + }, + [appConfig.svgLibrary], + ); + + // --- Available platforms --- + + const availablePlatforms = useMemo(() => { + if (!appConfig.platforms) return []; + const available = platformOrder.filter((key) => { + const data = appConfig.platforms[key] as RemnawavePlatformData | undefined; + return data && data.apps && data.apps.length > 0; + }); + if (detectedPlatform && available.includes(detectedPlatform)) { + return [detectedPlatform, ...available.filter((p) => p !== detectedPlatform)]; + } + return available; + }, [appConfig.platforms, detectedPlatform]); + + // --- Auto-select platform & app --- + + useEffect(() => { + if (selectedApp || !availablePlatforms.length) return; + const platform = availablePlatforms[0]; + const data = appConfig.platforms[platform] as RemnawavePlatformData | undefined; + if (!data?.apps?.length) return; + const app = data.apps.find((a) => a.featured) || data.apps[0]; + if (app) { + setSelectedApp(app); + setActivePlatformKey(platform); + } + }, [appConfig.platforms, availablePlatforms, selectedApp]); + + // --- Button renderer (delegates to BlockButtons component) --- + + const renderBlockButtons = useCallback( + (buttons: RemnawaveButtonClient[] | undefined, variant: 'light' | 'subtle') => ( + + ), + [ + appConfig.subscriptionUrl, + appConfig.hideLink, + selectedApp?.deepLink, + isLight, + getLocalizedText, + getBaseTranslation, + getSvgHtml, + onOpenDeepLink, + ], + ); + + // --- Current platform data --- + + const currentPlatformKey = activePlatformKey || availablePlatforms[0]; + const currentPlatformData = currentPlatformKey + ? (appConfig.platforms[currentPlatformKey] as RemnawavePlatformData | undefined) + : undefined; + const currentPlatformApps = currentPlatformData?.apps || []; + + // Platform display name + const getPlatformDisplayName = useCallback( + (key: string): string => { + const data = appConfig.platforms[key] as RemnawavePlatformData | undefined; + if (data?.displayName) { + const name = getLocalizedText(data.displayName); + if (name) return name; + } + if (appConfig.platformNames?.[key]) { + return getLocalizedText(appConfig.platformNames[key]); + } + const fallback: Record = { + 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..f0ee2d0 --- /dev/null +++ b/src/components/connection/blocks/AccordionBlock.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react'; +import { getColorGradient } from '@/utils/colorParser'; +import { ThemeIcon } from './ThemeIcon'; +import type { BlockRendererProps } from './types'; + +export function AccordionBlock({ + blocks, + isMobile, + isLight, + getLocalizedText, + getSvgHtml, + renderBlockButtons, +}: BlockRendererProps) { + const [openIndex, setOpenIndex] = useState(0); + + return ( +
+ {blocks.map((block, index) => { + const gradientStyle = getColorGradient(block.svgIconColor || 'cyan', isLight); + const isOpen = openIndex === index; + + return ( +
+ {/* Control */} + + {/* Panel */} +
+
+

+ {getLocalizedText(block.description)} +

+ {renderBlockButtons(block.buttons, 'light')} +
+
+
+ ); + })} +
+ ); +} diff --git a/src/components/connection/blocks/BlockButtons.tsx b/src/components/connection/blocks/BlockButtons.tsx new file mode 100644 index 0000000..2ee6c8b --- /dev/null +++ b/src/components/connection/blocks/BlockButtons.tsx @@ -0,0 +1,159 @@ +import { useState, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { RemnawaveButtonClient, LocalizedText } from '@/types'; + +// eslint-disable-next-line no-script-url +const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']; + +function isValidDeepLink(url: string | undefined): boolean { + if (!url) return false; + const lowerUrl = url.toLowerCase().trim(); + if (dangerousSchemes.some((s) => lowerUrl.startsWith(s))) return false; + return lowerUrl.includes('://'); +} + +function isValidExternalUrl(url: string | undefined): boolean { + if (!url) return false; + const lowerUrl = url.toLowerCase().trim(); + if (dangerousSchemes.some((s) => lowerUrl.startsWith(s))) return false; + return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://'); +} + +const CopyIcon = () => ( + + + +); + +const CheckIcon = () => ( + + + +); + +interface BlockButtonsProps { + buttons: RemnawaveButtonClient[] | undefined; + variant: 'light' | 'subtle'; + isLight?: boolean; + subscriptionUrl: string | null; + hideLink?: boolean; + deepLink?: string | null; + getLocalizedText: (text: LocalizedText | undefined) => string; + getBaseTranslation: (key: string, i18nKey: string) => string; + getSvgHtml: (key: string | undefined) => string; + onOpenDeepLink: (url: string) => void; +} + +export function BlockButtons({ + buttons, + variant, + isLight, + subscriptionUrl, + hideLink, + deepLink, + getLocalizedText, + getBaseTranslation, + getSvgHtml, + onOpenDeepLink, +}: BlockButtonsProps) { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async (url: string) => { + try { + await navigator.clipboard.writeText(url); + } catch { + const textarea = document.createElement('textarea'); + textarea.value = url; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, []); + + if (!buttons || buttons.length === 0) return null; + + const baseClass = + variant === 'light' + ? isLight + ? 'rounded-xl border border-accent-500/50 px-4 py-2 text-sm font-medium text-accent-600 shadow-sm transition-all hover:bg-accent-500/10' + : 'rounded-xl border border-accent-500/40 px-4 py-2 text-sm font-medium text-accent-400 transition-all hover:bg-accent-500/10' + : isLight + ? 'rounded-xl px-3 py-1.5 text-sm font-medium text-dark-300 transition-all hover:bg-dark-700/30' + : 'rounded-xl px-3 py-1.5 text-sm font-medium text-dark-300 transition-all hover:bg-dark-700/50'; + + return ( +
+ {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 || deepLink || subscriptionUrl; + if (!url || !isValidDeepLink(url)) return null; + return ( + + ); + } + + if (btn.type === 'copyButton') { + if (hideLink) return null; + const url = btn.resolvedUrl || subscriptionUrl; + if (!url) return null; + return ( + + ); + } + + // external + const href = btn.link || btn.url || ''; + if (!isValidExternalUrl(href)) return null; + return ( + + {btnIcon} + {btnText} + + ); + })} +
+ ); +} diff --git a/src/components/connection/blocks/CardsBlock.tsx b/src/components/connection/blocks/CardsBlock.tsx new file mode 100644 index 0000000..0c528d8 --- /dev/null +++ b/src/components/connection/blocks/CardsBlock.tsx @@ -0,0 +1,47 @@ +import { getColorGradient } from '@/utils/colorParser'; +import { ThemeIcon } from './ThemeIcon'; +import type { BlockRendererProps } from './types'; + +export function CardsBlock({ + blocks, + isMobile, + isLight, + getLocalizedText, + getSvgHtml, + renderBlockButtons, +}: BlockRendererProps) { + return ( +
+ {blocks.map((block, index) => { + const gradientStyle = getColorGradient(block.svgIconColor || 'cyan', isLight); + + 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..ff2e5f5 --- /dev/null +++ b/src/components/connection/blocks/MinimalBlock.tsx @@ -0,0 +1,46 @@ +import { getColorGradient } from '@/utils/colorParser'; +import { ThemeIcon } from './ThemeIcon'; +import type { BlockRendererProps } from './types'; + +export function MinimalBlock({ + blocks, + isMobile, + isLight, + getLocalizedText, + getSvgHtml, + renderBlockButtons, +}: BlockRendererProps) { + return ( +
+ {blocks.map((block, index) => { + const gradientStyle = getColorGradient(block.svgIconColor || 'cyan', isLight); + 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..4fc626f --- /dev/null +++ b/src/components/connection/blocks/TimelineBlock.tsx @@ -0,0 +1,46 @@ +import { getColorGradientSolid } from '@/utils/colorParser'; +import { ThemeIcon } from './ThemeIcon'; +import type { BlockRendererProps } from './types'; + +export function TimelineBlock({ + blocks, + isMobile, + isLight, + getLocalizedText, + getSvgHtml, + renderBlockButtons, +}: BlockRendererProps) { + return ( +
+ {blocks.map((block, index) => { + const gradientStyle = getColorGradientSolid(block.svgIconColor || 'cyan', isLight); + 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..633e0e9 --- /dev/null +++ b/src/components/connection/blocks/index.ts @@ -0,0 +1,6 @@ +export { CardsBlock } from './CardsBlock'; +export { TimelineBlock } from './TimelineBlock'; +export { AccordionBlock } from './AccordionBlock'; +export { MinimalBlock } from './MinimalBlock'; +export { BlockButtons } from './BlockButtons'; +export type { BlockRendererProps } from './types'; diff --git a/src/components/connection/blocks/types.ts b/src/components/connection/blocks/types.ts new file mode 100644 index 0000000..61580b2 --- /dev/null +++ b/src/components/connection/blocks/types.ts @@ -0,0 +1,13 @@ +import type { RemnawaveBlockClient, RemnawaveButtonClient, LocalizedText } from '@/types'; + +export interface BlockRendererProps { + blocks: RemnawaveBlockClient[]; + isMobile: boolean; + isLight: boolean; + getLocalizedText: (text: LocalizedText | undefined) => string; + getSvgHtml: (key: string | undefined) => string; + renderBlockButtons: ( + buttons: RemnawaveButtonClient[] | undefined, + variant: 'light' | 'subtle', + ) => React.ReactNode; +} diff --git a/src/components/data-display/EmptyState/EmptyState.tsx b/src/components/data-display/EmptyState/EmptyState.tsx deleted file mode 100644 index 466d02c..0000000 --- a/src/components/data-display/EmptyState/EmptyState.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { forwardRef, type ReactNode } from 'react'; -import { motion } from 'framer-motion'; -import { cn } from '@/lib/utils'; -import { Button, type ButtonProps } from '@/components/primitives/Button'; -import { fadeIn, fadeInTransition } from '@/components/motion/transitions'; - -export interface EmptyStateProps { - icon?: ReactNode; - title: string; - description?: string; - action?: { - label: string; - onClick: () => void; - variant?: ButtonProps['variant']; - }; - className?: string; -} - -export const EmptyState = forwardRef( - ({ icon, title, description, action, className }, ref) => { - return ( - - {/* Icon */} - {icon && ( -
- {icon} -
- )} - - {/* Title */} -

{title}

- - {/* Description */} - {description &&

{description}

} - - {/* Action */} - {action && ( -
- -
- )} -
- ); - }, -); - -EmptyState.displayName = 'EmptyState'; diff --git a/src/components/data-display/EmptyState/index.ts b/src/components/data-display/EmptyState/index.ts deleted file mode 100644 index cbf85e5..0000000 --- a/src/components/data-display/EmptyState/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { EmptyState, type EmptyStateProps } from './EmptyState'; diff --git a/src/components/data-display/index.ts b/src/components/data-display/index.ts index d3e63c3..353156e 100644 --- a/src/components/data-display/index.ts +++ b/src/components/data-display/index.ts @@ -1,3 +1,2 @@ export * from './Card'; export * from './StatCard'; -export * from './EmptyState'; diff --git a/src/components/layout/AppShell/AppHeader.tsx b/src/components/layout/AppShell/AppHeader.tsx index 9a0a9b1..4799b62 100644 --- a/src/components/layout/AppShell/AppHeader.tsx +++ b/src/components/layout/AppShell/AppHeader.tsx @@ -1,4 +1,4 @@ -import { Link, useLocation } from 'react-router-dom'; +import { Link, useLocation } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { useState, useEffect } from 'react'; diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index 7b4bf61..8df6534 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -1,26 +1,19 @@ -import { useEffect, useState, useCallback, useRef } from 'react'; -import { useLocation, useNavigate, Link } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { useLocation, Link } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useAuthStore } from '@/store/auth'; -import { useBackButton, useHaptic } from '@/platform'; +import { useHaptic } from '@/platform'; import { useTelegramSDK } from '@/hooks/useTelegramSDK'; import { useTheme } from '@/hooks/useTheme'; +import { useBranding } from '@/hooks/useBranding'; +import { useFeatureFlags } from '@/hooks/useFeatureFlags'; +import { useScrollRestoration } from '@/hooks/useScrollRestoration'; import { themeColorsApi } from '@/api/themeColors'; -import { referralApi } from '@/api/referral'; -import { wheelApi } from '@/api/wheel'; -import { contestsApi } from '@/api/contests'; -import { pollsApi } from '@/api/polls'; -import { - brandingApi, - getCachedBranding, - setCachedBranding, - preloadLogo, - isLogoPreloaded, -} from '@/api/branding'; -import { setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK'; +import { isLogoPreloaded } from '@/api/branding'; import { cn } from '@/lib/utils'; +import { UI } from '@/config/constants'; import WebSocketNotifications from '@/components/WebSocketNotifications'; import SuccessNotificationModal from '@/components/SuccessNotificationModal'; @@ -176,9 +169,6 @@ const MoonIcon = ({ className }: { className?: string }) => ( ); -const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'; -const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V'; - interface AppShellProps { children: React.ReactNode; } @@ -186,20 +176,17 @@ interface AppShellProps { export function AppShell({ children }: AppShellProps) { const { t } = useTranslation(); const location = useLocation(); - const navigate = useNavigate(); - const { isAdmin, isAuthenticated, logout } = useAuthStore(); - const { - isFullscreen, - safeAreaInset, - contentSafeAreaInset, - requestFullscreen, - isTelegramWebApp, - platform, - isMobile, - } = useTelegramSDK(); + const { isAdmin, logout } = useAuthStore(); + const { isFullscreen, safeAreaInset, contentSafeAreaInset, platform, isMobile } = + useTelegramSDK(); const haptic = useHaptic(); const { toggleTheme, isDark } = useTheme(); + // Extracted hooks + const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding(); + const { referralEnabled, wheelEnabled, hasContests, hasPolls } = useFeatureFlags(); + useScrollRestoration(); + // Theme toggle visibility const { data: enabledThemes } = useQuery({ queryKey: ['enabled-themes'], @@ -214,147 +201,6 @@ export function AppShell({ children }: AppShellProps) { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [isKeyboardOpen, setIsKeyboardOpen] = useState(false); - // Scroll position restoration for admin pages - const scrollPositions = useRef>({}); - - // Disable browser's automatic scroll restoration - useEffect(() => { - if ('scrollRestoration' in history) { - history.scrollRestoration = 'manual'; - } - }, []); - - // Continuously save scroll position for current path - useEffect(() => { - const currentPath = location.pathname; - - // Only track scroll for admin pages - if (!currentPath.startsWith('/admin')) return; - - const handleScroll = () => { - scrollPositions.current[currentPath] = window.scrollY; - }; - - // Save on scroll - window.addEventListener('scroll', handleScroll, { passive: true }); - - // Restore scroll position immediately (synchronous) - const savedPosition = scrollPositions.current[currentPath]; - if (savedPosition !== undefined && savedPosition > 0) { - // Immediate restore - window.scrollTo({ top: savedPosition, behavior: 'instant' }); - } - - return () => { - window.removeEventListener('scroll', handleScroll); - }; - }, [location.pathname]); - - // Branding - const { data: branding } = useQuery({ - queryKey: ['branding'], - queryFn: async () => { - const data = await brandingApi.getBranding(); - setCachedBranding(data); - preloadLogo(data); - return data; - }, - initialData: getCachedBranding() ?? undefined, - staleTime: 60000, - enabled: isAuthenticated, - }); - - const appName = branding ? branding.name : FALLBACK_NAME; - const logoLetter = branding?.logo_letter || FALLBACK_LOGO; - const hasCustomLogo = branding?.has_custom_logo || false; - const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null; - - // Set document title - useEffect(() => { - document.title = appName || 'VPN'; - }, [appName]); - - // Update favicon - useEffect(() => { - if (!logoUrl) return; - - const link = - document.querySelector("link[rel*='icon']") || - document.createElement('link'); - link.type = 'image/x-icon'; - link.rel = 'shortcut icon'; - link.href = logoUrl; - document.head.appendChild(link); - }, [logoUrl]); - - // Fullscreen setting from server - const { data: fullscreenSetting } = useQuery({ - queryKey: ['fullscreen-enabled'], - queryFn: brandingApi.getFullscreenEnabled, - staleTime: 60000, - }); - - // Apply fullscreen setting when loaded from server - // Only apply on mobile Telegram (iOS/Android) - desktop doesn't need fullscreen - useEffect(() => { - if (!fullscreenSetting || !isTelegramWebApp) return; - - // Update cache for future app starts - setCachedFullscreenEnabled(fullscreenSetting.enabled); - - // Request fullscreen if enabled, not already fullscreen, and on mobile Telegram - if (fullscreenSetting.enabled && !isFullscreen && isMobile) { - requestFullscreen(); - } - }, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]); - - // Feature flags - const { data: referralTerms } = useQuery({ - queryKey: ['referral-terms'], - queryFn: referralApi.getReferralTerms, - enabled: isAuthenticated, - staleTime: 60000, - retry: false, - }); - - const { data: wheelConfig } = useQuery({ - queryKey: ['wheel-config'], - queryFn: wheelApi.getConfig, - enabled: isAuthenticated, - staleTime: 60000, - retry: false, - }); - - const { data: contestsCount } = useQuery({ - queryKey: ['contests-count'], - queryFn: contestsApi.getCount, - enabled: isAuthenticated, - staleTime: 60000, - retry: false, - }); - - const { data: pollsCount } = useQuery({ - queryKey: ['polls-count'], - queryFn: pollsApi.getCount, - enabled: isAuthenticated, - staleTime: 60000, - retry: false, - }); - - // BackButton for Telegram Mini App - // Don't show back button on main tab pages (bottom nav) - users navigate via tabs - const mainTabPaths = ['/', '/subscription', '/balance', '/referral', '/support', '/wheel']; - const isMainTabPage = mainTabPaths.includes(location.pathname); - const handleBack = useCallback(() => { - if (mobileMenuOpen) { - setMobileMenuOpen(false); - return; - } - navigate(-1); - }, [mobileMenuOpen, navigate]); - - useBackButton(isMainTabPage ? null : handleBack); - // Keyboard detection for hiding bottom nav useEffect(() => { const handleFocusIn = (e: FocusEvent) => { @@ -405,7 +251,8 @@ export function AppShell({ children }: AppShellProps) { // Calculate header height based on fullscreen mode (only on mobile Telegram) // On iOS: contentSafeAreaInset.top includes status bar + dynamic island + Telegram header // On Android: safeAreaInset.top only includes status bar, need to add Telegram header height (~48px) - const telegramHeaderHeight = platform === 'android' ? 48 : 45; + const telegramHeaderHeight = + platform === 'android' ? UI.TELEGRAM_HEADER_ANDROID_PX : UI.TELEGRAM_HEADER_IOS_PX; const headerHeight = isMobileFullscreen ? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + telegramHeaderHeight : 64; @@ -421,7 +268,7 @@ export function AppShell({ children }: AppShellProps) { {/* Desktop Header */}
-
+
{/* Logo */}
@@ -448,7 +295,7 @@ export function AppShell({ children }: AppShellProps) { {/* Center Navigation */} -
); diff --git a/src/components/layout/AppShell/Aurora.tsx b/src/components/layout/AppShell/Aurora.tsx index 415f7d5..e73f726 100644 --- a/src/components/layout/AppShell/Aurora.tsx +++ b/src/components/layout/AppShell/Aurora.tsx @@ -154,6 +154,14 @@ export function Aurora() { const background = isDark ? themeColors.darkBackground : themeColors.lightBackground; const surface = isDark ? themeColors.darkSurface : themeColors.lightSurface; + // Refs for initial color values (WebGL context shouldn't recreate on color change) + const backgroundRef = useRef(background); + backgroundRef.current = background; + const surfaceRef = useRef(surface); + surfaceRef.current = surface; + const accentRef = useRef(themeColors.accent); + accentRef.current = themeColors.accent; + // Initialize WebGL context once (only depends on isEnabled) useEffect(() => { if (!isEnabled || !containerRef.current) return; @@ -175,7 +183,11 @@ export function Aurora() { const geometry = new Triangle(gl); - const colorStops = generateColorStops(background, surface, themeColors.accent); + const colorStops = generateColorStops( + backgroundRef.current, + surfaceRef.current, + accentRef.current, + ); const colorStopsArray = colorStops .map((hex) => { const c = new Color(hex); @@ -238,7 +250,7 @@ export function Aurora() { rendererRef.current = null; programRef.current = null; }; - }, [isEnabled]); // eslint-disable-line react-hooks/exhaustive-deps + }, [isEnabled]); // Update color uniforms reactively without recreating WebGL context useEffect(() => { diff --git a/src/components/layout/AppShell/DesktopSidebar.tsx b/src/components/layout/AppShell/DesktopSidebar.tsx index ecebd2f..9b71433 100644 --- a/src/components/layout/AppShell/DesktopSidebar.tsx +++ b/src/components/layout/AppShell/DesktopSidebar.tsx @@ -1,4 +1,4 @@ -import { Link, useLocation } from 'react-router-dom'; +import { Link, useLocation } from 'react-router'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; import { useQuery } from '@tanstack/react-query'; diff --git a/src/components/layout/AppShell/MobileBottomNav.tsx b/src/components/layout/AppShell/MobileBottomNav.tsx index a51cc30..72463b7 100644 --- a/src/components/layout/AppShell/MobileBottomNav.tsx +++ b/src/components/layout/AppShell/MobileBottomNav.tsx @@ -1,4 +1,4 @@ -import { Link, useLocation } from 'react-router-dom'; +import { Link, useLocation } from 'react-router'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; diff --git a/src/components/layout/AppShell/MovingGradient.tsx b/src/components/layout/AppShell/MovingGradient.tsx deleted file mode 100644 index d53ec30..0000000 --- a/src/components/layout/AppShell/MovingGradient.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { motion } from 'framer-motion'; - -/** - * Animated moving gradient background. - * Uses CSS variables for colors to support theme switching. - * Lightweight - pure CSS gradients with Framer Motion animation. - */ -export function MovingGradient() { - return ( -
- {/* Base background */} -
- - {/* Animated gradient layer */} - - - {/* Second animated layer - moves in opposite direction */} - - - {/* Subtle noise texture overlay */} -
-
- ); -} diff --git a/src/components/motion/transitions.ts b/src/components/motion/transitions.ts index cb2aefc..8480b77 100644 --- a/src/components/motion/transitions.ts +++ b/src/components/motion/transitions.ts @@ -7,13 +7,6 @@ export const springTransition: Transition = { damping: 30, }; -// Smooth spring for larger movements -export const smoothSpring: Transition = { - type: 'spring', - stiffness: 300, - damping: 25, -}; - // Expo easing curve (Linear-style) export const easeOutExpo = [0.16, 1, 0.3, 1] as const; @@ -42,27 +35,6 @@ export const slideUpTransition: Transition = { ease: easeOutExpo, }; -// Slide down animation (for dropdowns, popovers) -export const slideDown: Variants = { - initial: { opacity: 0, y: -8 }, - animate: { opacity: 1, y: 0 }, - exit: { opacity: 0 }, -}; - -// Slide from right (for sheets, sidebars) -export const slideRight: Variants = { - initial: { opacity: 0, x: 20 }, - animate: { opacity: 1, x: 0 }, - exit: { opacity: 0, x: 20 }, -}; - -// Slide from left -export const slideLeft: Variants = { - initial: { opacity: 0, x: -20 }, - animate: { opacity: 1, x: 0 }, - exit: { opacity: 0, x: -20 }, -}; - // Scale animation (for modals, dialogs) export const scale: Variants = { initial: { opacity: 0, scale: 0.95 }, @@ -92,16 +64,6 @@ export const staggerContainer: Variants = { }, }; -// Fast stagger for dense lists -export const fastStaggerContainer: Variants = { - initial: {}, - animate: { - transition: { - staggerChildren: 0.03, - }, - }, -}; - // Stagger item (use with staggerContainer) // Exit is instant to avoid visual glitches in Telegram Mini App export const staggerItem: Variants = { diff --git a/src/components/navigation/CommandPalette/CommandPalette.tsx b/src/components/navigation/CommandPalette/CommandPalette.tsx index 6aceaa3..c949ec7 100644 --- a/src/components/navigation/CommandPalette/CommandPalette.tsx +++ b/src/components/navigation/CommandPalette/CommandPalette.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { AnimatePresence, motion } from 'framer-motion'; import * as DialogPrimitive from '@radix-ui/react-dialog'; diff --git a/src/components/primitives/Dialog/Dialog.tsx b/src/components/primitives/Dialog/Dialog.tsx index c1bfe23..ff88a50 100644 --- a/src/components/primitives/Dialog/Dialog.tsx +++ b/src/components/primitives/Dialog/Dialog.tsx @@ -10,6 +10,12 @@ import { import { cn } from '@/lib/utils'; import { backdrop, backdropTransition, scale, scaleTransition } from '../../motion/transitions'; +export { + Trigger as DialogTrigger, + Portal as DialogPortal, + Close as DialogClose, +} from '@radix-ui/react-dialog'; + // Close icon const CloseIcon = () => ( @@ -41,15 +47,6 @@ export const Dialog = ({ children, open, onOpenChange, ...props }: DialogProps) ); }; -// Trigger -export const DialogTrigger = DialogPrimitive.Trigger; - -// Portal -export const DialogPortal = DialogPrimitive.Portal; - -// Close -export const DialogClose = DialogPrimitive.Close; - // Overlay export type DialogOverlayProps = ComponentPropsWithoutRef; @@ -86,7 +83,7 @@ export const DialogContent = forwardRef( const { open } = useContext(DialogContext); return ( - + {open && ( <> @@ -131,7 +128,7 @@ export const DialogContent = forwardRef( )} - + ); }, ); diff --git a/src/components/primitives/DropdownMenu/DropdownMenu.tsx b/src/components/primitives/DropdownMenu/DropdownMenu.tsx index b923db7..78e8c67 100644 --- a/src/components/primitives/DropdownMenu/DropdownMenu.tsx +++ b/src/components/primitives/DropdownMenu/DropdownMenu.tsx @@ -5,6 +5,15 @@ import { cn } from '@/lib/utils'; import { usePlatform } from '@/platform'; import { dropdown, dropdownTransition } from '../../motion/transitions'; +export { + Root as DropdownMenu, + Trigger as DropdownMenuTrigger, + Group as DropdownMenuGroup, + Portal as DropdownMenuPortal, + Sub as DropdownMenuSub, + RadioGroup as DropdownMenuRadioGroup, +} from '@radix-ui/react-dropdown-menu'; + // Icons const CheckIcon = () => ( @@ -36,24 +45,6 @@ const DotIcon = () => ( ); -// Root -export const DropdownMenu = DropdownMenuPrimitive.Root; - -// Trigger -export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; - -// Group -export const DropdownMenuGroup = DropdownMenuPrimitive.Group; - -// Portal -export const DropdownMenuPortal = DropdownMenuPrimitive.Portal; - -// Sub -export const DropdownMenuSub = DropdownMenuPrimitive.Sub; - -// RadioGroup -export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; - // SubTrigger export interface DropdownMenuSubTriggerProps extends ComponentPropsWithoutRef< typeof DropdownMenuPrimitive.SubTrigger diff --git a/src/components/primitives/Popover/Popover.tsx b/src/components/primitives/Popover/Popover.tsx index 90b94e0..64f3e71 100644 --- a/src/components/primitives/Popover/Popover.tsx +++ b/src/components/primitives/Popover/Popover.tsx @@ -4,6 +4,13 @@ import { forwardRef, type ComponentPropsWithoutRef } from 'react'; import { cn } from '@/lib/utils'; import { dropdown, dropdownTransition } from '../../motion/transitions'; +export { + Root as Popover, + Trigger as PopoverTrigger, + Anchor as PopoverAnchor, + Close as PopoverClose, +} from '@radix-ui/react-popover'; + // Close icon const CloseIcon = () => ( @@ -17,18 +24,6 @@ const CloseIcon = () => ( ); -// Root -export const Popover = PopoverPrimitive.Root; - -// Trigger -export const PopoverTrigger = PopoverPrimitive.Trigger; - -// Anchor -export const PopoverAnchor = PopoverPrimitive.Anchor; - -// Close -export const PopoverClose = PopoverPrimitive.Close; - // Content export interface PopoverContentProps extends ComponentPropsWithoutRef< typeof PopoverPrimitive.Content diff --git a/src/components/primitives/Select/Select.tsx b/src/components/primitives/Select/Select.tsx index f7cfdf7..7b65634 100644 --- a/src/components/primitives/Select/Select.tsx +++ b/src/components/primitives/Select/Select.tsx @@ -5,6 +5,8 @@ import { cn } from '@/lib/utils'; import { usePlatform } from '@/platform'; import { dropdown, dropdownTransition } from '../../motion/transitions'; +export { Root as Select, Group as SelectGroup } from '@radix-ui/react-select'; + // Icons const ChevronDownIcon = () => ( @@ -30,9 +32,6 @@ const CheckIcon = () => ( ); -// Root -export const Select = SelectPrimitive.Root; - // Trigger export interface SelectTriggerProps extends ComponentPropsWithoutRef< typeof SelectPrimitive.Trigger @@ -149,9 +148,6 @@ export const SelectItem = forwardRef( SelectItem.displayName = 'SelectItem'; -// Group -export const SelectGroup = SelectPrimitive.Group; - // Label export type SelectLabelProps = ComponentPropsWithoutRef; diff --git a/src/components/primitives/Sheet/Sheet.tsx b/src/components/primitives/Sheet/Sheet.tsx index 1c29024..33cd6de 100644 --- a/src/components/primitives/Sheet/Sheet.tsx +++ b/src/components/primitives/Sheet/Sheet.tsx @@ -10,7 +10,6 @@ import { } from 'react'; import { cn } from '@/lib/utils'; import { usePlatform } from '@/platform'; -import { useBackButton } from '@/platform'; import { backdrop, backdropTransition, @@ -18,6 +17,12 @@ import { sheetTransition, } from '../../motion/transitions'; +export { + Trigger as SheetTrigger, + Portal as SheetPortal, + Close as SheetClose, +} from '@radix-ui/react-dialog'; + // Close icon const CloseIcon = () => ( @@ -79,15 +84,6 @@ export const Sheet = ({ children, open, onOpenChange, onClose, ...props }: Sheet ); }; -// Trigger -export const SheetTrigger = DialogPrimitive.Trigger; - -// Portal -export const SheetPortal = DialogPrimitive.Portal; - -// Close -export const SheetClose = DialogPrimitive.Close; - // Overlay export type SheetOverlayProps = ComponentPropsWithoutRef; @@ -138,9 +134,6 @@ export const SheetContent = forwardRef( const { open, onClose } = useContext(SheetContext); const { haptic } = usePlatform(); const dragControls = useDragControls(); - // Back button integration - useBackButton(open ? onClose : null); - const handleDragEnd = useCallback( (_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) => { const velocity = info.velocity.y; @@ -157,7 +150,7 @@ export const SheetContent = forwardRef( ); return ( - + {open && ( <> @@ -221,7 +214,7 @@ export const SheetContent = forwardRef( )} - + ); }, ); diff --git a/src/components/primitives/Tooltip/Tooltip.tsx b/src/components/primitives/Tooltip/Tooltip.tsx index 3d05860..2d0bd13 100644 --- a/src/components/primitives/Tooltip/Tooltip.tsx +++ b/src/components/primitives/Tooltip/Tooltip.tsx @@ -4,14 +4,11 @@ import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react import { cn } from '@/lib/utils'; import { tooltip, tooltipTransition } from '../../motion/transitions'; -// Provider - wrap your app with this -export const TooltipProvider = TooltipPrimitive.Provider; - -// Root -export const Tooltip = TooltipPrimitive.Root; - -// Trigger -export const TooltipTrigger = TooltipPrimitive.Trigger; +export { + Provider as TooltipProvider, + Root as Tooltip, + Trigger as TooltipTrigger, +} from '@radix-ui/react-tooltip'; // Content export type TooltipContentProps = ComponentPropsWithoutRef; @@ -66,10 +63,10 @@ export const SimpleTooltip = ({ delayDuration = 200, className, }: SimpleTooltipProps) => ( - - {children} + + {children} {content} - + ); diff --git a/src/components/ui/BentoCard.tsx b/src/components/ui/BentoCard.tsx index 85097cf..ceeef2a 100644 --- a/src/components/ui/BentoCard.tsx +++ b/src/components/ui/BentoCard.tsx @@ -1,4 +1,4 @@ -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import { forwardRef, useCallback } from 'react'; import { useHaptic } from '@/platform'; diff --git a/src/components/ui/Sheet.tsx b/src/components/ui/Sheet.tsx index 3ee4874..486a165 100644 --- a/src/components/ui/Sheet.tsx +++ b/src/components/ui/Sheet.tsx @@ -1,6 +1,5 @@ import { useEffect, useRef, useCallback, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; -import { useBackButton } from '@/platform'; import { useHaptic } from '@/platform'; export interface SheetProps { @@ -105,9 +104,6 @@ export function Sheet({ const haptic = useHaptic(); - // BackButton integration - useBackButton(isOpen ? onClose : null); - // Calculate current height based on snap point const currentHeight = `${snapPoints[currentSnapIndex] * 100}vh`; diff --git a/src/components/ui/Skeleton.tsx b/src/components/ui/Skeleton.tsx index 56f15c9..0de2d57 100644 --- a/src/components/ui/Skeleton.tsx +++ b/src/components/ui/Skeleton.tsx @@ -131,69 +131,3 @@ export function Skeleton({ return renderSkeleton(0); } - -// Convenience components for common use cases -export function TextSkeleton({ - lines = 1, - className = '', - lastLineWidth = '60%', -}: { - lines?: number; - className?: string; - lastLineWidth?: string; -}) { - return ( -
- {Array.from({ length: lines }).map((_, i) => ( - - ))} -
- ); -} - -export function AvatarSkeleton({ - size = 40, - className = '', -}: { - size?: number; - className?: string; -}) { - return ; -} - -export function CardSkeleton({ - count = 1, - className = '', -}: { - count?: number; - className?: string; -}) { - return ; -} - -export function ListSkeleton({ - count = 3, - className = '', -}: { - count?: number; - className?: string; -}) { - return ( -
- -
- ); -} - -export function BentoSkeleton({ - count = 1, - className = '', -}: { - count?: number; - className?: string; -}) { - return ; -} - -// Default export for backwards compatibility -export default BentoSkeleton; diff --git a/src/components/wheel/FortuneWheel.tsx b/src/components/wheel/FortuneWheel.tsx index 110c094..4563a27 100644 --- a/src/components/wheel/FortuneWheel.tsx +++ b/src/components/wheel/FortuneWheel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useMemo, memo } from 'react'; +import { useEffect, useRef, useState, memo } from 'react'; import type { WheelPrize } from '../../api/wheel'; interface FortuneWheelProps { @@ -24,20 +24,6 @@ const FortuneWheel = memo(function FortuneWheel({ const wheelRef = useRef(null); const accumulatedRotation = useRef(0); const [displayRotation, setDisplayRotation] = useState(0); - const [lightPhase, setLightPhase] = useState(0); - - // Animated lights effect - always running, speed depends on spinning state - useEffect(() => { - // Faster animation when spinning, slower when idle - const interval = setInterval( - () => { - setLightPhase((p) => (p + 1) % 20); - }, - isSpinning ? 100 : 300, - ); // 100ms when spinning, 300ms when idle - - return () => clearInterval(interval); - }, [isSpinning]); useEffect(() => { if (isSpinning && targetRotation !== null && wheelRef.current) { @@ -57,18 +43,6 @@ const FortuneWheel = memo(function FortuneWheel({ } }, [isSpinning, targetRotation, onSpinComplete]); - // Memoize light pattern calculation - const lightPattern = useMemo(() => { - const numLights = isSpinning ? 3 : 4; // 3 lights when spinning, 4 when idle - - return Array.from({ length: 20 }, (_, i) => { - // Calculate distance from current lightPhase - const distance = (i - lightPhase + 20) % 20; - // Light is on if within range [0, numLights) - return distance < numLights; - }); - }, [isSpinning, lightPhase]); - if (prizes.length === 0) { return (
@@ -257,36 +231,53 @@ const FortuneWheel = memo(function FortuneWheel({ strokeWidth="2" /> - {/* LED lights on outer ring - positioned toward outer edge to avoid bleeding into sectors */} - {Array.from({ length: 20 }).map((_, i) => { - const angle = (i * 18 - 90) * (Math.PI / 180); - const ledRadius = outerRadius + 3; - const dotX = center + ledRadius * Math.cos(angle); - const dotY = center + ledRadius * Math.sin(angle); - const isLit = lightPattern[i] ?? i % 2 === 0; - return ( - - {isLit && ( + {/* LED chase animation — pure CSS, no React re-renders */} + + + {Array.from({ length: 20 }).map((_, i) => { + const angle = (i * 18 - 90) * (Math.PI / 180); + const ledRadius = outerRadius + 3; + const dotX = center + ledRadius * Math.cos(angle); + const dotY = center + ledRadius * Math.sin(angle); + // Delay as fraction of full cycle — CSS handles speed via animation-duration + const delay = `${(i / 20) * 6}s`; + return ( + - )} - - - ); - })} + + + ); + })} + {/* Rotating wheel group */} preset.id === id); -} diff --git a/src/hooks/useBranding.ts b/src/hooks/useBranding.ts new file mode 100644 index 0000000..b68462d --- /dev/null +++ b/src/hooks/useBranding.ts @@ -0,0 +1,79 @@ +import { useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useAuthStore } from '@/store/auth'; +import { useTelegramSDK, setCachedFullscreenEnabled } from '@/hooks/useTelegramSDK'; +import { + brandingApi, + getCachedBranding, + setCachedBranding, + preloadLogo, + isLogoPreloaded, +} from '@/api/branding'; + +const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'; +const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V'; + +export function useBranding() { + const { isAuthenticated } = useAuthStore(); + const { isFullscreen, isTelegramWebApp, requestFullscreen, isMobile } = useTelegramSDK(); + + // Branding data + const { data: branding } = useQuery({ + queryKey: ['branding'], + queryFn: async () => { + const data = await brandingApi.getBranding(); + setCachedBranding(data); + preloadLogo(data); + return data; + }, + initialData: getCachedBranding() ?? undefined, + staleTime: 60000, + enabled: isAuthenticated, + }); + + const appName = branding ? branding.name : FALLBACK_NAME; + const logoLetter = branding?.logo_letter || FALLBACK_LOGO; + const hasCustomLogo = branding?.has_custom_logo || false; + const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null; + + // Set document title + useEffect(() => { + document.title = appName || 'VPN'; + }, [appName]); + + // Update favicon + useEffect(() => { + if (!logoUrl) return; + + const link = + document.querySelector("link[rel*='icon']") || + document.createElement('link'); + link.type = 'image/x-icon'; + link.rel = 'shortcut icon'; + link.href = logoUrl; + document.head.appendChild(link); + }, [logoUrl]); + + // Fullscreen setting from server + const { data: fullscreenSetting } = useQuery({ + queryKey: ['fullscreen-enabled'], + queryFn: brandingApi.getFullscreenEnabled, + staleTime: 60000, + }); + + useEffect(() => { + if (!fullscreenSetting || !isTelegramWebApp) return; + setCachedFullscreenEnabled(fullscreenSetting.enabled); + if (fullscreenSetting.enabled && !isFullscreen && isMobile) { + requestFullscreen(); + } + }, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen, isMobile]); + + return { + appName, + logoLetter, + hasCustomLogo, + logoUrl, + isLogoPreloaded, + }; +} diff --git a/src/hooks/useCurrency.ts b/src/hooks/useCurrency.ts index 842f817..c58462d 100644 --- a/src/hooks/useCurrency.ts +++ b/src/hooks/useCurrency.ts @@ -1,3 +1,4 @@ +import { useCallback, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { currencyApi, type ExchangeRates } from '../api/currency'; @@ -40,65 +41,93 @@ export function useCurrency() { const currencySymbol = t('common.currency'); // Format amount with currency conversion - const formatAmount = (rubAmount: number, decimals: number = 2): string => { - if (isRussian) { - return rubAmount.toFixed(decimals); - } + const formatAmount = useCallback( + (rubAmount: number, decimals: number = 2): string => { + if (isRussian) { + return rubAmount.toFixed(decimals); + } - // Convert to target currency - const convertedAmount = currencyApi.convertFromRub( - rubAmount, - targetCurrency as keyof ExchangeRates, - exchangeRates, - ); + // Convert to target currency + const convertedAmount = currencyApi.convertFromRub( + rubAmount, + targetCurrency as keyof ExchangeRates, + exchangeRates, + ); - // For IRR (Iranian Toman), use no decimals as amounts are large - if (targetCurrency === 'IRR') { - return Math.round(convertedAmount).toLocaleString('fa-IR'); - } + // For IRR (Iranian Toman), use no decimals as amounts are large + if (targetCurrency === 'IRR') { + return Math.round(convertedAmount).toLocaleString('fa-IR'); + } - return convertedAmount.toFixed(decimals); - }; + return convertedAmount.toFixed(decimals); + }, + [isRussian, targetCurrency, exchangeRates], + ); // Format amount with currency symbol - const formatWithCurrency = (rubAmount: number, decimals: number = 2): string => { - return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`; - }; + const formatWithCurrency = useCallback( + (rubAmount: number, decimals: number = 2): string => { + return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`; + }, + [formatAmount, currencySymbol], + ); // Format amount with + sign (for earnings/bonuses) - const formatPositive = (rubAmount: number, decimals: number = 2): string => { - return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`; - }; + const formatPositive = useCallback( + (rubAmount: number, decimals: number = 2): string => { + return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`; + }, + [formatAmount, currencySymbol], + ); // Get raw converted amount (for calculations) - const convertAmount = (rubAmount: number): number => { - if (isRussian) { - return rubAmount; - } - return currencyApi.convertFromRub( - rubAmount, - targetCurrency as keyof ExchangeRates, - exchangeRates, - ); - }; + const convertAmount = useCallback( + (rubAmount: number): number => { + if (isRussian) { + return rubAmount; + } + return currencyApi.convertFromRub( + rubAmount, + targetCurrency as keyof ExchangeRates, + exchangeRates, + ); + }, + [isRussian, targetCurrency, exchangeRates], + ); // Convert from user's currency back to rubles - const convertToRub = (amount: number): number => { - if (isRussian) { - return amount; - } - return currencyApi.convertToRub(amount, targetCurrency as keyof ExchangeRates, exchangeRates); - }; + const convertToRub = useCallback( + (amount: number): number => { + if (isRussian) { + return amount; + } + return currencyApi.convertToRub(amount, targetCurrency as keyof ExchangeRates, exchangeRates); + }, + [isRussian, targetCurrency, exchangeRates], + ); - return { - exchangeRates, - targetCurrency, - isRussian, - currencySymbol, - formatAmount, - formatWithCurrency, - formatPositive, - convertAmount, - convertToRub, - }; + return useMemo( + () => ({ + exchangeRates, + targetCurrency, + isRussian, + currencySymbol, + formatAmount, + formatWithCurrency, + formatPositive, + convertAmount, + convertToRub, + }), + [ + exchangeRates, + targetCurrency, + isRussian, + currencySymbol, + formatAmount, + formatWithCurrency, + formatPositive, + convertAmount, + convertToRub, + ], + ); } diff --git a/src/hooks/useFavoriteSettings.ts b/src/hooks/useFavoriteSettings.ts index ced7d33..be078d6 100644 --- a/src/hooks/useFavoriteSettings.ts +++ b/src/hooks/useFavoriteSettings.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; +import { STORAGE_KEYS } from '../config/constants'; -const STORAGE_KEY = 'admin_favorite_settings'; +const STORAGE_KEY = STORAGE_KEYS.FAVORITE_SETTINGS; export function useFavoriteSettings() { const [favorites, setFavorites] = useState(() => { diff --git a/src/hooks/useFeatureFlags.ts b/src/hooks/useFeatureFlags.ts new file mode 100644 index 0000000..594e4a9 --- /dev/null +++ b/src/hooks/useFeatureFlags.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query'; +import { useAuthStore } from '@/store/auth'; +import { referralApi } from '@/api/referral'; +import { wheelApi } from '@/api/wheel'; +import { contestsApi } from '@/api/contests'; +import { pollsApi } from '@/api/polls'; + +export function useFeatureFlags() { + const { isAuthenticated } = useAuthStore(); + + const { data: referralTerms } = useQuery({ + queryKey: ['referral-terms'], + queryFn: referralApi.getReferralTerms, + enabled: isAuthenticated, + staleTime: 60000, + retry: false, + }); + + const { data: wheelConfig } = useQuery({ + queryKey: ['wheel-config'], + queryFn: wheelApi.getConfig, + enabled: isAuthenticated, + staleTime: 60000, + retry: false, + }); + + const { data: contestsCount } = useQuery({ + queryKey: ['contests-count'], + queryFn: contestsApi.getCount, + enabled: isAuthenticated, + staleTime: 60000, + retry: false, + }); + + const { data: pollsCount } = useQuery({ + queryKey: ['polls-count'], + queryFn: pollsApi.getCount, + enabled: isAuthenticated, + staleTime: 60000, + retry: false, + }); + + return { + referralEnabled: referralTerms?.is_enabled, + wheelEnabled: wheelConfig?.is_enabled, + hasContests: (contestsCount?.count ?? 0) > 0, + hasPolls: (pollsCount?.count ?? 0) > 0, + }; +} diff --git a/src/hooks/useScrollRestoration.ts b/src/hooks/useScrollRestoration.ts new file mode 100644 index 0000000..4917a65 --- /dev/null +++ b/src/hooks/useScrollRestoration.ts @@ -0,0 +1,40 @@ +import { useEffect, useRef } from 'react'; +import { useLocation } from 'react-router'; + +/** + * Saves and restores scroll position for admin pages. + * Disables browser's automatic scroll restoration. + */ +export function useScrollRestoration() { + const location = useLocation(); + const scrollPositions = useRef>({}); + + // Disable browser's automatic scroll restoration + useEffect(() => { + if ('scrollRestoration' in history) { + history.scrollRestoration = 'manual'; + } + }, []); + + // Save/restore scroll for admin pages + useEffect(() => { + const currentPath = location.pathname; + + if (!currentPath.startsWith('/admin')) return; + + const handleScroll = () => { + scrollPositions.current[currentPath] = window.scrollY; + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + + const savedPosition = scrollPositions.current[currentPath]; + if (savedPosition !== undefined && savedPosition > 0) { + window.scrollTo({ top: savedPosition, behavior: 'instant' }); + } + + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, [location.pathname]); +} diff --git a/src/hooks/useTelegramDnd.ts b/src/hooks/useTelegramDnd.ts deleted file mode 100644 index 740953d..0000000 --- a/src/hooks/useTelegramDnd.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useCallback } from 'react'; -import { useTelegramSDK } from './useTelegramSDK'; - -/** - * Hook for drag-and-drop operations in Telegram Mini App. - * Note: Vertical swipes are now globally disabled at app init, - * so this hook just provides no-op callbacks for compatibility. - */ -export function useTelegramDnd() { - const { isTelegramWebApp } = useTelegramSDK(); - - const onDragStart = useCallback(() => { - // No-op: swipes are globally disabled - }, []); - - const onDragEnd = useCallback(() => { - // No-op: swipes are globally disabled - }, []); - - const onDragCancel = useCallback(() => { - // No-op: swipes are globally disabled - }, []); - - return { - onDragStart, - onDragEnd, - onDragCancel, - isTelegramWebApp, - }; -} diff --git a/src/hooks/useTelegramWebApp.ts b/src/hooks/useTelegramWebApp.ts deleted file mode 100644 index 7fdf0be..0000000 --- a/src/hooks/useTelegramWebApp.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * @deprecated This hook is deprecated. Use useTelegramSDK instead. - * This file is kept for backward compatibility and re-exports from useTelegramSDK. - */ - -import { useTelegramSDK } from './useTelegramSDK'; - -// Re-export everything from useTelegramSDK for backward compatibility -export { - getCachedFullscreenEnabled, - setCachedFullscreenEnabled, - isInTelegramWebApp, - isTelegramMobile, -} from './useTelegramSDK'; - -/** - * @deprecated Use useTelegramSDK instead - * Hook for Telegram WebApp API integration - backward compatible wrapper - */ -export function useTelegramWebApp() { - const sdk = useTelegramSDK(); - - return { - isTelegramWebApp: sdk.isTelegramWebApp, - isFullscreen: sdk.isFullscreen, - isFullscreenSupported: sdk.isFullscreenSupported, - safeAreaInset: sdk.safeAreaInset, - contentSafeAreaInset: sdk.contentSafeAreaInset, - requestFullscreen: sdk.requestFullscreen, - exitFullscreen: sdk.exitFullscreen, - toggleFullscreen: sdk.toggleFullscreen, - disableVerticalSwipes: sdk.disableVerticalSwipes, - enableVerticalSwipes: sdk.enableVerticalSwipes, - webApp: null, - }; -} diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts index a13a51e..45d20c7 100644 --- a/src/hooks/useTheme.ts +++ b/src/hooks/useTheme.ts @@ -1,11 +1,12 @@ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme'; import { themeColorsApi } from '../api/themeColors'; +import { STORAGE_KEYS } from '../config/constants'; type Theme = 'dark' | 'light'; -const THEME_KEY = 'cabinet-theme'; -const ENABLED_THEMES_KEY = 'cabinet-enabled-themes'; +const THEME_KEY = STORAGE_KEYS.THEME; +const ENABLED_THEMES_KEY = STORAGE_KEYS.ENABLED_THEMES; // Fetch enabled themes from API async function fetchEnabledThemes(): Promise { @@ -82,18 +83,21 @@ export function useTheme() { return enabled.dark ? 'dark' : 'light'; }); + const themeRef = useRef(theme); + themeRef.current = theme; + // Fetch enabled themes on mount useEffect(() => { fetchEnabledThemes().then((data) => { setEnabledThemes(data); setIsLoading(false); // If current theme is disabled, switch to enabled one - if (!data[theme]) { + if (!data[themeRef.current]) { const newTheme = data.dark ? 'dark' : 'light'; setThemeState(newTheme); } }); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, []); // Listen for localStorage changes (when admin updates enabled themes from other tabs) useEffect(() => { diff --git a/src/hooks/useThemeColors.ts b/src/hooks/useThemeColors.ts index 2bb5d58..1fb29d9 100644 --- a/src/hooks/useThemeColors.ts +++ b/src/hooks/useThemeColors.ts @@ -2,66 +2,7 @@ import { useEffect } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { themeColorsApi } from '../api/themeColors'; import { ThemeColors, DEFAULT_THEME_COLORS, SHADE_LEVELS, ColorPalette } from '../types/theme'; - -// Convert hex to RGB values -function hexToRgb(hex: string): { r: number; g: number; b: number } { - // Handle shorthand hex - if (hex.length === 4) { - hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]; - } - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - return { r, g, b }; -} - -// Convert hex to HSL -function hexToHsl(hex: string): { h: number; s: number; l: number } { - const { r, g, b } = hexToRgb(hex); - const rNorm = r / 255; - const gNorm = g / 255; - const bNorm = b / 255; - - const max = Math.max(rNorm, gNorm, bNorm); - const min = Math.min(rNorm, gNorm, bNorm); - let h = 0; - let s = 0; - const l = (max + min) / 2; - - if (max !== min) { - const d = max - min; - s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - - switch (max) { - case rNorm: - h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6; - break; - case gNorm: - h = ((bNorm - rNorm) / d + 2) / 6; - break; - case bNorm: - h = ((rNorm - gNorm) / d + 4) / 6; - break; - } - } - - return { h: h * 360, s: s * 100, l: l * 100 }; -} - -// Convert HSL to RGB values -function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { - s /= 100; - l /= 100; - - const a = s * Math.min(l, 1 - l); - const f = (n: number) => { - const k = (n + h / 30) % 12; - const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); - return Math.round(255 * color); - }; - - return { r: f(0), g: f(8), b: f(4) }; -} +import { hexToRgb, hexToHsl, hslToRgb } from '../utils/colorConversion'; // Convert RGB to string format for CSS variable function rgbToString(r: number, g: number, b: number): string { diff --git a/src/hooks/useUserThemePreferences.ts b/src/hooks/useUserThemePreferences.ts index bbf6e47..08bdf79 100644 --- a/src/hooks/useUserThemePreferences.ts +++ b/src/hooks/useUserThemePreferences.ts @@ -7,8 +7,9 @@ import { DEFAULT_USER_PREFERENCES, BORDER_RADIUS_VALUES, } from '../types/theme'; +import { STORAGE_KEYS } from '../config/constants'; -const STORAGE_KEY = 'user_theme_preferences'; +const STORAGE_KEY = STORAGE_KEYS.USER_THEME_PREFS; /** * Parse preferences from storage string diff --git a/src/locales/en.json b/src/locales/en.json index b4eabe4..96b5497 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -161,6 +161,8 @@ "or": "or", "registerHint": "To register with email, first log in via Telegram, then link your email in settings.", "telegramRequired": "Telegram authorization required", + "telegramRetryFailed": "Authorization failed. Close the app and try again.", + "telegramReopenHint": "If the problem persists, close and reopen the app", "telegramNotConfigured": "Telegram bot is not configured", "authenticating": "Authenticating...", "orOpenInApp": "Or open the bot in the app", @@ -346,7 +348,8 @@ "changeApp": "Change app", "selectPlatform": "Select platform", "app": "app", - "apps": "apps" + "apps": "apps", + "tutorial": "Tutorial" }, "myDevices": "My Devices", "noDevices": "No connected devices", @@ -1305,6 +1308,9 @@ "confirmDeleteText": "This action cannot be undone. The tariff will be permanently deleted.", "confirmDeleteWithSubscriptions": "This tariff has {{count}} active subscriptions. After deletion, users will need to select a new tariff when renewing.", "deleteSuccess": "Tariff deleted successfully", + "saveOrder": "Save order", + "orderSaved": "Order saved", + "dragToReorder": "Drag to reorder", "days": "days", "days_one": "{{count}} day", "days_other": "{{count}} days", @@ -2626,6 +2632,12 @@ "hint": "Click check button after subscribing", "notSubscribed": "You haven't subscribed to the channel yet", "checkError": "Check failed. Please try again later." + }, + "blacklisted": { + "title": "Access Denied", + "defaultMessage": "Your account has been blocked.", + "reason": "Reason", + "contactSupport": "If you believe this is an error, please contact support." } } } diff --git a/src/locales/fa.json b/src/locales/fa.json index 4eb2d39..fcce861 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -153,6 +153,8 @@ "or": "یا", "registerHint": "برای ثبت نام با ایمیل، ابتدا با تلگرام وارد شوید، سپس ایمیل خود را در تنظیمات متصل کنید.", "telegramRequired": "نیاز به تایید تلگرام", + "telegramRetryFailed": "تایید هویت ناموفق بود. برنامه را ببندید و دوباره باز کنید.", + "telegramReopenHint": "اگر مشکل ادامه دارد، برنامه را ببندید و دوباره باز کنید", "telegramNotConfigured": "ربات تلگرام پیکربندی نشده", "authenticating": "در حال تایید هویت...", "orOpenInApp": "یا ربات را در برنامه باز کنید", @@ -298,7 +300,8 @@ "changeApp": "تغییر برنامه", "selectPlatform": "انتخاب پلتفرم", "app": "برنامه", - "apps": "برنامه" + "apps": "برنامه", + "tutorial": "آموزش" }, "additionalOptions": { "title": "گزینه‌های اضافی", @@ -1011,6 +1014,9 @@ "confirmDeleteText": "این عمل قابل بازگشت نیست. تعرفه برای همیشه حذف می‌شود.", "confirmDeleteWithSubscriptions": "این تعرفه {{count}} اشتراک فعال دارد. پس از حذف، کاربران هنگام تمدید باید تعرفه جدید انتخاب کنند.", "deleteSuccess": "تعرفه با موفقیت حذف شد", + "saveOrder": "ذخیره ترتیب", + "orderSaved": "ترتیب ذخیره شد", + "dragToReorder": "برای تغییر ترتیب بکشید", "days": "روز", "days_other": "{{count}} روز", "tabs": { @@ -2270,6 +2276,12 @@ "hint": "پس از عضویت دکمه بررسی را بزنید", "notSubscribed": "شما هنوز در کانال عضو نشده‌اید", "checkError": "بررسی ناموفق بود. لطفاً بعداً دوباره امتحان کنید." + }, + "blacklisted": { + "title": "دسترسی ممنوع", + "defaultMessage": "حساب شما مسدود شده است.", + "reason": "دلیل", + "contactSupport": "اگر فکر می‌کنید این اشتباه است، با پشتیبانی تماس بگیرید." } } } diff --git a/src/locales/ru.json b/src/locales/ru.json index 4a48ae4..7585644 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -164,6 +164,8 @@ "or": "или", "registerHint": "Для регистрации по email сначала авторизуйтесь через Telegram, затем привяжите email в настройках.", "telegramRequired": "Требуется авторизация через Telegram", + "telegramRetryFailed": "Авторизация не удалась. Закройте приложение и откройте заново.", + "telegramReopenHint": "Если проблема повторяется — закройте и откройте приложение заново", "telegramNotConfigured": "Telegram бот не настроен", "authenticating": "Авторизация...", "orOpenInApp": "Или откройте бота в приложении", @@ -368,7 +370,8 @@ "changeApp": "Сменить приложение", "selectPlatform": "Выберите платформу", "app": "приложение", - "apps": "приложений" + "apps": "приложений", + "tutorial": "Инструкция" }, "myDevices": "Мои устройства", "noDevices": "Нет подключенных устройств", @@ -1823,6 +1826,9 @@ "confirmDeleteText": "Это действие нельзя отменить. Тариф будет удалён навсегда.", "confirmDeleteWithSubscriptions": "У этого тарифа {{count}} активных подписок. При удалении пользователям потребуется выбрать новый тариф при продлении.", "deleteSuccess": "Тариф успешно удален", + "saveOrder": "Сохранить порядок", + "orderSaved": "Порядок сохранён", + "dragToReorder": "Перетащите для изменения порядка", "days": "дней", "days_one": "{{count}} день", "days_few": "{{count}} дня", @@ -3339,6 +3345,12 @@ "hint": "После подписки нажмите кнопку проверки", "notSubscribed": "Вы ещё не подписались на канал", "checkError": "Ошибка проверки. Попробуйте позже." + }, + "blacklisted": { + "title": "Доступ запрещён", + "defaultMessage": "Ваш аккаунт заблокирован.", + "reason": "Причина", + "contactSupport": "Если вы считаете, что это ошибка, обратитесь в поддержку." } } } diff --git a/src/locales/zh.json b/src/locales/zh.json index adbae2a..7520d66 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -153,6 +153,8 @@ "or": "或", "registerHint": "要通过邮箱注册,请先通过Telegram登录,然后在设置中绑定邮箱。", "telegramRequired": "需要Telegram授权", + "telegramRetryFailed": "授权失败。请关闭应用后重新打开。", + "telegramReopenHint": "如果问题持续存在,请关闭并重新打开应用", "telegramNotConfigured": "Telegram机器人未配置", "authenticating": "正在验证...", "orOpenInApp": "或在应用中打开机器人", @@ -298,7 +300,8 @@ "changeApp": "更换应用", "selectPlatform": "选择平台", "app": "个应用", - "apps": "个应用" + "apps": "个应用", + "tutorial": "教程" }, "additionalOptions": { "title": "附加选项", @@ -1049,6 +1052,9 @@ "confirmDeleteText": "此操作不可撤销。套餐将被永久删除。", "confirmDeleteWithSubscriptions": "此套餐有 {{count}} 个活跃订阅。删除后,用户续订时需要选择新套餐。", "deleteSuccess": "套餐删除成功", + "saveOrder": "保存排序", + "orderSaved": "排序已保存", + "dragToReorder": "拖动以重新排序", "days": "天", "days_other": "{{count}} 天", "tabs": { @@ -2151,6 +2157,12 @@ "hint": "订阅后点击检查按钮", "notSubscribed": "您还没有订阅该频道", "checkError": "检查失败。请稍后重试。" + }, + "blacklisted": { + "title": "访问被拒绝", + "defaultMessage": "您的帐户已被封锁。", + "reason": "原因", + "contactSupport": "如果您认为这是错误,请联系客服。" } }, "banSystem": { diff --git a/src/main.tsx b/src/main.tsx index 5d53a22..6d79dbd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { init, restoreInitData, + retrieveRawInitData, mountMiniApp, miniAppReady, mountThemeParams, @@ -19,7 +20,9 @@ import { requestFullscreen, isFullscreen, } from '@telegram-apps/sdk-react'; +import { clearStaleSessionIfNeeded } from './utils/token'; import { AppWithNavigator } from './AppWithNavigator'; +import { ErrorBoundary } from './components/ErrorBoundary'; import { initLogoPreload } from './api/branding'; import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegramSDK'; import './i18n'; @@ -36,6 +39,9 @@ if (!alreadyInitialized) { init(); restoreInitData(); + // Сбрасываем старые токены если init data изменился (новая сессия Telegram) + clearStaleSessionIfNeeded(retrieveRawInitData() || null); + // Mount components — each in its own try/catch so one failure doesn't block others try { mountMiniApp(); @@ -100,8 +106,10 @@ const queryClient = new QueryClient({ ReactDOM.createRoot(document.getElementById('root')!).render( - - - + + + + + , ); diff --git a/src/pages/AdminApps.tsx b/src/pages/AdminApps.tsx index 8ed24d2..fb7abe2 100644 --- a/src/pages/AdminApps.tsx +++ b/src/pages/AdminApps.tsx @@ -1,1220 +1,119 @@ -import { useState, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { - adminAppsApi, - AppDefinition, - LocalizedText, - AppStep, - AppButton, - exportToRemnawaveFormat, - importFromRemnawaveFormat, - RemnawaveConfig, - importFromRemnawaveFormat as convertRemnawave, -} from '../api/adminApps'; -import { useBackButton } from '../platform/hooks/useBackButton'; +import { adminAppsApi } from '../api/adminApps'; 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} -