diff --git a/package-lock.json b/package-lock.json index 9e21138..d688d40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "cabinet-frontend", - "version": "1.5.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { @@ -13,15 +13,33 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@lottiefiles/dotlottie-react": "^0.8.0", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.8.0", + "@telegram-apps/react-router-integration": "^1.0.1", + "@telegram-apps/sdk-react": "^3.3.9", "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", + "ogl": "^1.0.11", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^13.5.0", - "react-router-dom": "^7.13.0", + "react-router-dom": "^6.30.3", + "tailwind-merge": "^3.4.0", "zustand": "^4.4.0" }, "devDependencies": { @@ -1025,6 +1043,44 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", + "integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz", + "integrity": "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.5" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1184,6 +1240,1042 @@ "node": ">= 8" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.4.tgz", + "integrity": "sha512-kaeiyGCe844dkb9AVF+rb4yTyb1LiLN/e3es3nLiRyN4dC8AduBYPMnnNlDjX2VDOcvDEiPnRNMJeWCfsX0txg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "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", @@ -1525,6 +2617,133 @@ "react": "^18 || ^19" } }, + "node_modules/@telegram-apps/bridge": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@telegram-apps/bridge/-/bridge-2.11.0.tgz", + "integrity": "sha512-kBZjWRRp/lxKeQ8r8cDWUY9EjxUtyeh/9xTQjsjuGRsCR5XTO1cyVbvcvqzHn53csGx3aBs+fOR3Pk3b6w2JHg==", + "deprecated": "This package is not supported anymore. Use @tma.js/bridge instead", + "license": "MIT", + "dependencies": { + "@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", + "mitt": "^3.0.1", + "valibot": "1.0.0" + } + }, + "node_modules/@telegram-apps/navigation": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@telegram-apps/navigation/-/navigation-1.0.14.tgz", + "integrity": "sha512-bqNgF/J8Po7ZtsELm3E1a6aPr7awwxO3sIqD8J6u12urOlGoW5+1KxKKbkPa58mgXuQdsltd8apI+OVy0IYiUA==", + "license": "MIT" + }, + "node_modules/@telegram-apps/react-router-integration": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@telegram-apps/react-router-integration/-/react-router-integration-1.0.1.tgz", + "integrity": "sha512-q5fC0ihXyUDqrSdLQ3u3gypmjI/rMU2mTHeAz9IU1F1bl5X+J17LxWmgLp+OlD0Y97bq2XYmc+UbKrYh+eN3Wg==", + "license": "MIT", + "peerDependencies": { + "@telegram-apps/sdk": "^1", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-router-dom": "^6.22.3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@telegram-apps/sdk": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@telegram-apps/sdk/-/sdk-1.1.3.tgz", + "integrity": "sha512-gvcXNttbCouDNTxjDUbO5FS/BdaHYO0ibSddQxiYitShPIDZDxGon0Eh3OSsgUIVPSQSNdXqkXaeqYgA+jeY0A==", + "license": "MIT", + "peer": true + }, + "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", + "integrity": "sha512-85jF1ICT8sYCNzXu19SqJrfrS8XslsvV14KUcToiyL7H5ZMXHt0JQKM+QJgULjnLjEswweQ4/7Bd7mNULf/BIg==", + "license": "MIT", + "dependencies": { + "@telegram-apps/sdk": "^3.11.8" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "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", + "integrity": "sha512-1P1kdCLX7MfETGPxH7f3UZKIsdE7Tz5S7QmN4Km1sbYQMakD5Bi1NecSMR7/wnHp50gWMI1JzENcMtCEmouhSg==", + "license": "MIT" + }, + "node_modules/@telegram-apps/toolkit": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@telegram-apps/toolkit/-/toolkit-2.1.3.tgz", + "integrity": "sha512-LPUBL7hxQTOr+Dowyk9a1O82nQS4ja4+OYiYWtvqq5nNUHC6Gbbus0zGWCbFcj9CLnIzaeb5HWOg5iSnhoRcyg==", + "license": "MIT" + }, + "node_modules/@telegram-apps/transformers": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@telegram-apps/transformers/-/transformers-2.2.6.tgz", + "integrity": "sha512-MMBRs3demeBT9Cd614KKZmak7eEyNdEbfu99a0SwEEJe2oIODjJLrUxrhUcAOc5wvTRfrKka27VXVgruauLhdg==", + "deprecated": "This package is not supported anymore. Use @tma.js/transfomers instead", + "license": "MIT", + "dependencies": { + "@telegram-apps/toolkit": "^2.1.3", + "@telegram-apps/types": "^2.0.3", + "valibot": "1.0.0-beta.14" + } + }, + "node_modules/@telegram-apps/transformers/node_modules/valibot": { + "version": "1.0.0-beta.14", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.0.0-beta.14.tgz", + "integrity": "sha512-tLyV2rE5QL6U29MFy3xt4AqMrn+/HErcp2ZThASnQvPMwfSozjV1uBGKIGiegtZIGjinJqn0SlBdannf18wENA==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@telegram-apps/types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@telegram-apps/types/-/types-2.0.3.tgz", + "integrity": "sha512-pXh9BdnLZF3e2BGc4WL+RTRMAUpqKpaSP3Rs8rB4WyRwIoRSGWFKE4gtT/9m42LGixB8YVwdo/pJ+9XO765XEA==", + "deprecated": "This package is not supported anymore. Use @tma.js/types instead", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1617,8 +2836,9 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -2029,6 +3249,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2100,6 +3332,15 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/better-promises": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/better-promises/-/better-promises-0.4.1.tgz", + "integrity": "sha512-cDW7eMvhvCoWzSih5o/OxsAgTUfP05yGMq77xNZUTmcZoNU9vEeFZJ+yJJi4lnocvxFrVFKsG0Yxt7ZnuYJEig==", + "license": "MIT", + "dependencies": { + "error-kid": "^0.0.7" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2281,6 +3522,18 @@ "node": ">= 6" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -2314,6 +3567,31 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2377,19 +3655,6 @@ "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", @@ -2459,6 +3724,12 @@ "node": ">=0.4.0" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2523,6 +3794,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/error-kid": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/error-kid/-/error-kid-0.0.7.tgz", + "integrity": "sha512-8mFs7ZaDWlBFgjOy4lHLMCe2+KZfZXGx5GOgh2VQ/M1CCIvSWzbl2OMl+5fdZgrgoUfhTeF+NPhmqfEvUhN8yw==", + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3016,6 +4293,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "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==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.29.2", + "motion-utils": "^12.29.2", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3087,6 +4391,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -3703,6 +5016,27 @@ "node": "*" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "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==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.29.2" + } + }, + "node_modules/motion-utils": { + "version": "12.29.2", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz", + "integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3798,6 +5132,12 @@ "node": ">= 6" } }, + "node_modules/ogl": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ogl/-/ogl-1.0.11.tgz", + "integrity": "sha512-kUpC154AFfxi16pmZUK4jk3J+8zxwTWGPo03EoYA8QPbzikHoaC82n6pNTbd+oEaJonaE8aPWBlX7ad9zrqLsA==", + "license": "Unlicense" + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -4323,42 +5663,106 @@ "node": ">=0.10.0" } }, - "node_modules/react-router": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", - "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", "license": "MIT", "dependencies": { - "cookie": "^1.0.1", - "set-cookie-parser": "^2.6.0" + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" }, "engines": { - "node": ">=20.0.0" + "node": ">=10" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { - "react-dom": { + "@types/react": { "optional": true } } }, - "node_modules/react-router-dom": { - "version": "7.13.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", - "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", "license": "MIT", "dependencies": { - "react-router": "7.13.0" + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=10" }, "peerDependencies": { - "react": ">=18", - "react-dom": ">=18" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "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==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.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" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/read-cache": { @@ -4538,12 +5942,6 @@ "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", @@ -4725,6 +6123,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -4891,7 +6299,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "peer": true, "bin": { @@ -4967,6 +6375,49 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/use-sync-external-store": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", @@ -4983,6 +6434,20 @@ "dev": true, "license": "MIT" }, + "node_modules/valibot": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.0.0.tgz", + "integrity": "sha512-1Hc0ihzWxBar6NGeZv7fPLY0QuxFMyxwYR2sF1Blu7Wq7EnremwY2W02tit2ij2VJT8HcSkHAQqmFfl77f73Yw==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 558b344..2b8e1bf 100644 --- a/package.json +++ b/package.json @@ -21,15 +21,33 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@lottiefiles/dotlottie-react": "^0.8.0", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@radix-ui/react-visually-hidden": "^1.2.4", "@tanstack/react-query": "^5.8.0", + "@telegram-apps/react-router-integration": "^1.0.1", + "@telegram-apps/sdk-react": "^3.3.9", "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", + "ogl": "^1.0.11", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^13.5.0", - "react-router-dom": "^7.13.0", + "react-router-dom": "^6.30.3", + "tailwind-merge": "^3.4.0", "zustand": "^4.4.0" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index 23cc45b..a762d57 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,6 @@ import PageLoader from './components/common/PageLoader'; import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking'; import { saveReturnUrl } from './utils/token'; import { useAnalyticsCounters } from './hooks/useAnalyticsCounters'; - // Auth pages - load immediately (small) import Login from './pages/Login'; import TelegramCallback from './pages/TelegramCallback'; @@ -31,22 +30,40 @@ const Wheel = lazy(() => import('./pages/Wheel')); // Admin pages - lazy load (only for admins) const AdminPanel = lazy(() => import('./pages/AdminPanel')); const AdminTickets = lazy(() => import('./pages/AdminTickets')); +const AdminTicketSettings = lazy(() => import('./pages/AdminTicketSettings')); const AdminSettings = lazy(() => import('./pages/AdminSettings')); const AdminApps = lazy(() => import('./pages/AdminApps')); const AdminWheel = lazy(() => import('./pages/AdminWheel')); const AdminTariffs = lazy(() => import('./pages/AdminTariffs')); +const AdminTariffCreate = lazy(() => import('./pages/AdminTariffCreate')); const AdminServers = lazy(() => import('./pages/AdminServers')); +const AdminServerEdit = lazy(() => import('./pages/AdminServerEdit')); const AdminDashboard = lazy(() => import('./pages/AdminDashboard')); const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem')); const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts')); +const AdminBroadcastCreate = lazy(() => import('./pages/AdminBroadcastCreate')); const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes')); +const AdminPromocodeCreate = lazy(() => import('./pages/AdminPromocodeCreate')); +const AdminPromocodeStats = lazy(() => import('./pages/AdminPromocodeStats')); +const AdminPromoGroups = lazy(() => import('./pages/AdminPromoGroups')); +const AdminPromoGroupCreate = lazy(() => import('./pages/AdminPromoGroupCreate')); const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns')); +const AdminCampaignCreate = lazy(() => import('./pages/AdminCampaignCreate')); +const AdminCampaignStats = lazy(() => import('./pages/AdminCampaignStats')); +const AdminCampaignEdit = lazy(() => import('./pages/AdminCampaignEdit')); const AdminUsers = lazy(() => import('./pages/AdminUsers')); const AdminPayments = lazy(() => import('./pages/AdminPayments')); const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods')); +const AdminPaymentMethodEdit = lazy(() => import('./pages/AdminPaymentMethodEdit')); const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers')); +const AdminPromoOfferTemplateEdit = lazy(() => import('./pages/AdminPromoOfferTemplateEdit')); +const AdminPromoOfferSend = lazy(() => import('./pages/AdminPromoOfferSend')); const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')); +const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail')); const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates')); +const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail')); +const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail')); +const AdminEmailTemplatePreview = lazy(() => import('./pages/AdminEmailTemplatePreview')); function ProtectedRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuthStore(); @@ -245,6 +262,16 @@ function App() { } /> + + + + + + } + /> } /> + + + + + + } + /> + + + + + + } + /> } /> + + + + + + } + /> } /> + + + + + + } + /> } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> } /> + + + + + + } + /> } /> + + + + + + } + /> + + + + + + } + /> } /> + + + + + + } + /> } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> {/* Catch all */} } /> diff --git a/src/AppWithNavigator.tsx b/src/AppWithNavigator.tsx new file mode 100644 index 0000000..fb05276 --- /dev/null +++ b/src/AppWithNavigator.tsx @@ -0,0 +1,102 @@ +import { useEffect, useMemo } from 'react'; +import { BrowserRouter, Router } from 'react-router-dom'; +import { useIntegration } from '@telegram-apps/react-router-integration'; +import { initNavigator } from '@telegram-apps/sdk'; +import App from './App'; +import { PlatformProvider } from './platform/PlatformProvider'; +import { ThemeColorsProvider } from './providers/ThemeColorsProvider'; +import { WebSocketProvider } from './providers/WebSocketProvider'; +import { ToastProvider } from './components/Toast'; +import { TooltipProvider } from './components/primitives/Tooltip'; + +/** + * Check if running inside Telegram Mini App + * Uses multiple checks to reliably detect Telegram environment + */ +function isTelegramMiniApp(): boolean { + if (typeof window === 'undefined') return false; + + const webApp = window.Telegram?.WebApp; + if (!webApp) return false; + + // Check 1: initDataUnsafe should have user data in real Telegram + const hasUserData = webApp.initDataUnsafe?.user?.id !== undefined; + + // Check 2: Platform should not be 'unknown' (which is default in browser) + const validPlatform = webApp.platform !== 'unknown' && webApp.platform !== ''; + + // Check 3: Version should be present (SDK loads in Telegram only) + const hasVersion = webApp.version !== undefined && webApp.version !== ''; + + return hasUserData || (validPlatform && hasVersion); +} + +/** + * Component wrapper for Telegram navigator setup. + * Integrates Telegram Mini Apps navigator with React Router to provide + * automatic BackButton management based on navigation history. + * Falls back to BrowserRouter when not in Telegram Mini App. + */ +/** + * Navigator-based router for Telegram Mini App + */ +function TelegramRouter({ children }: { children: React.ReactNode }) { + const navigator = useMemo(() => initNavigator('app-navigation-state', { hashMode: null }), []); + const [location, reactNavigator] = useIntegration(navigator); + + useEffect(() => { + try { + navigator.attach(); + return () => { + try { + navigator.detach(); + } catch (err) { + console.warn('Failed to detach navigator:', err); + } + }; + } catch (err) { + console.warn('Failed to attach navigator:', err); + } + }, [navigator]); + + return ( + + {children} + + ); +} + +export function AppWithNavigator() { + const isTelegram = useMemo(() => { + const result = isTelegramMiniApp(); + console.log('[AppWithNavigator] Platform detection:', { + isTelegram: result, + platform: window.Telegram?.WebApp?.platform, + hasUser: window.Telegram?.WebApp?.initDataUnsafe?.user?.id !== undefined, + version: window.Telegram?.WebApp?.version, + }); + return result; + }, []); + + // Common app content + const appContent = ( + + + + + + + + + + + + ); + + // Use Telegram navigator in Mini App, BrowserRouter elsewhere + if (isTelegram) { + return {appContent}; + } + + return {appContent}; +} diff --git a/src/api/client.ts b/src/api/client.ts index 53c1f83..40775ac 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -153,6 +153,20 @@ apiClient.interceptors.response.use( // Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала) if (error.response?.status === 401 && !originalRequest._retry) { + // Не обрабатываем 401 для авторизационных endpoints - пусть ошибка дойдет до компонента + const authEndpoints = [ + '/cabinet/auth/email/login', + '/cabinet/auth/telegram', + '/cabinet/auth/telegram/widget', + ]; + const requestUrl = originalRequest.url || ''; + const isAuthEndpoint = authEndpoints.some((endpoint) => requestUrl.includes(endpoint)); + + if (isAuthEndpoint) { + // Пробрасываем ошибку в компонент для показа сообщения пользователю + return Promise.reject(error); + } + originalRequest._retry = true; const newToken = await tokenRefreshManager.refreshAccessToken(); diff --git a/src/api/promo.ts b/src/api/promo.ts index 89a0f09..459f141 100644 --- a/src/api/promo.ts +++ b/src/api/promo.ts @@ -101,7 +101,9 @@ export const promoApi = { // Deactivate current active promo discount deactivateDiscount: async (): Promise => { - const response = await apiClient.post('/cabinet/promocode/deactivate-discount'); + const response = await apiClient.post( + '/cabinet/promocode/deactivate-discount', + ); return response.data; }, }; diff --git a/src/api/tariffs.ts b/src/api/tariffs.ts index a58224b..47bd121 100644 --- a/src/api/tariffs.ts +++ b/src/api/tariffs.ts @@ -236,4 +236,10 @@ export const tariffsApi = { const response = await apiClient.get('/cabinet/admin/tariffs/available-servers'); return response.data; }, + + // Get available promo groups for selection + getAvailablePromoGroups: async (): Promise<{ id: number; name: string }[]> => { + const response = await apiClient.get('/cabinet/admin/payment-methods/promo-groups'); + return response.data; + }, }; diff --git a/src/components/ChangeEmailModal.tsx b/src/components/ChangeEmailModal.tsx index bfbd11e..5d8f081 100644 --- a/src/components/ChangeEmailModal.tsx +++ b/src/components/ChangeEmailModal.tsx @@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { authApi } from '../api/auth'; import { useAuthStore } from '../store/auth'; import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; +import { useBackButton } from '@/platform'; // Icons const CloseIcon = () => ( @@ -55,7 +56,7 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM const { t } = useTranslation(); const queryClient = useQueryClient(); const { setUser } = useAuthStore(); - const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp(); + const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp(); const isMobileScreen = useIsMobile(); const emailInputRef = useRef(null); @@ -87,16 +88,8 @@ export default function ChangeEmailModal({ onClose, currentEmail }: ChangeEmailM return () => document.removeEventListener('keydown', handleKeyDown); }, [handleClose]); - // Telegram back button - useEffect(() => { - if (!webApp?.BackButton) return; - webApp.BackButton.show(); - webApp.BackButton.onClick(handleClose); - return () => { - webApp.BackButton.offClick(handleClose); - webApp.BackButton.hide(); - }; - }, [webApp, handleClose]); + // Telegram back button - using platform hook + useBackButton(handleClose); // Scroll lock useEffect(() => { diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index 1a2aaf5..57254a1 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { subscriptionApi } from '../api/subscription'; import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; +import { useBackButton, useHaptic } from '@/platform'; import type { AppInfo, AppConfig, LocalizedText } from '../types'; interface ConnectionModalProps { @@ -156,14 +157,18 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { const [showAppSelector, setShowAppSelector] = useState(false); const [selectedPlatform, setSelectedPlatform] = useState(null); - const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } = + 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) => { @@ -237,23 +242,13 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose; }, [showAppSelector, handleBack, handleClose]); - // Управление BackButton — эффект запускается только при mount/unmount - // Используем стабильный обработчик через ref, чтобы избежать мигания - useEffect(() => { - if (!webApp?.BackButton) return; + // BackButton using platform hook - always close/back, ref provides current handler + const handleBackButton = useCallback(() => { + hapticRef.current('light'); + backButtonHandlerRef.current(); + }, []); - const stableHandler = () => { - backButtonHandlerRef.current(); - }; - - webApp.BackButton.show(); - webApp.BackButton.onClick(stableHandler); - - return () => { - webApp.BackButton.offClick(stableHandler); - webApp.BackButton.hide(); - }; - }, [webApp]); // Только webApp в зависимостях! + useBackButton(handleBackButton); useEffect(() => { document.body.style.overflow = 'hidden'; @@ -453,12 +448,14 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { return (
- + {!isTelegramWebApp && ( + + )}

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

@@ -525,12 +522,14 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { return (
- + {!isTelegramWebApp && ( + + )}

{platformNames[selectedPlatform] || selectedPlatform} @@ -614,12 +613,14 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {

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

- + {!isTelegramWebApp && ( + + )}
+ +
+

+
+ ); + + if (typeof document !== 'undefined') { + return createPortal(modalContent, document.body); + } + return modalContent; +} + +// ------- Main Component ------- + interface PromoOffersSectionProps { className?: string; } export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) { const { t } = useTranslation(); + const navigate = useNavigate(); const queryClient = useQueryClient(); + const { isTelegramWebApp } = useTelegramSDK(); const [claimingId, setClaimingId] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); + const [showConfirmModal, setShowConfirmModal] = useState(false); // Fetch available offers const { data: offers = [], isLoading: offersLoading } = useQuery({ @@ -156,6 +264,26 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio }, }); + // Deactivate discount mutation + const deactivateMutation = useMutation({ + mutationFn: promoApi.clearActiveDiscount, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['active-discount'] }); + queryClient.invalidateQueries({ queryKey: ['promo-offers'] }); + queryClient.invalidateQueries({ queryKey: ['subscription'] }); + queryClient.invalidateQueries({ queryKey: ['purchase-options'] }); + setShowConfirmModal(false); + setSuccessMessage(t('promo.deactivate.success')); + setTimeout(() => setSuccessMessage(null), 5000); + }, + onError: (error: unknown) => { + const axiosErr = error as { response?: { data?: { detail?: string } } }; + setErrorMessage(axiosErr.response?.data?.detail || t('promo.deactivate.error')); + setShowConfirmModal(false); + setTimeout(() => setErrorMessage(null), 5000); + }, + }); + const handleClaim = (offerId: number) => { setClaimingId(offerId); setErrorMessage(null); @@ -163,6 +291,49 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio claimMutation.mutate(offerId); }; + const handleUseNow = () => { + navigate('/subscription', { state: { scrollToExtend: true } }); + }; + + const handleDeactivateClick = () => { + setErrorMessage(null); + setSuccessMessage(null); + + // Use Telegram native popup in Mini App + if (isTelegramWebApp && window.Telegram?.WebApp?.showPopup) { + window.Telegram.WebApp.showPopup( + { + title: t('promo.deactivate.confirmTitle'), + message: t('promo.deactivate.confirmDescription', { + percent: activeDiscount?.discount_percent || 0, + }), + buttons: [ + { id: 'cancel', type: 'cancel' }, + { id: 'confirm', type: 'destructive', text: t('promo.deactivate.confirm') }, + ], + }, + (button_id: string) => { + if (button_id === 'confirm') { + deactivateMutation.mutate(); + } + }, + ); + } else { + // Fallback to modal for web + setShowConfirmModal(true); + } + }; + + const handleConfirmDeactivate = () => { + deactivateMutation.mutate(); + }; + + const handleCloseConfirmModal = () => { + if (!deactivateMutation.isPending) { + setShowConfirmModal(false); + } + }; + // Filter unclaimed and active offers const availableOffers = offers.filter((o) => o.is_active && !o.is_claimed); @@ -177,36 +348,56 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio return (
- {/* Active Discount Banner */} + {/* Active Discount Banner with actions */} {activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && ( -
-
-
- +
+
+ {/* Header */} +
+
+ 🏷️ +
+
+
+

+ {t('promo.offers.discountActiveTitle', { + percent: activeDiscount.discount_percent, + })} +

+ + -{activeDiscount.discount_percent}% + +
+
+ {activeDiscount.expires_at && ( +
+ + + {t('promo.offers.expires', { + time: formatTimeLeft(activeDiscount.expires_at, t), + })} + +
+ )} +
+
-
-
-

- {t('promo.offers.discountActiveTitle', { - percent: activeDiscount.discount_percent, - })} -

- - {t('promo.offers.statusActive')} - -
-
- {activeDiscount.expires_at && ( -
- - - {t('promo.offers.expires', { - time: formatTimeLeft(activeDiscount.expires_at, t), - })} - -
- )} -
+ + {/* Action Buttons */} +
+ +
@@ -235,8 +426,8 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent transition-colors hover:border-orange-500/50" >
-
- {getOfferIcon(offer.effect_type)} +
+ {getOfferIcon(offer.effect_type, offer.discount_percent)}
@@ -248,7 +439,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio )}

{getOfferDescription(offer, t)}

-
+
@@ -258,8 +449,10 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
)} + + {/* Deactivation Confirmation Modal */} + {activeDiscount && ( + + )}
); } diff --git a/src/components/SuccessNotificationModal.tsx b/src/components/SuccessNotificationModal.tsx index f80c44c..dcb4c45 100644 --- a/src/components/SuccessNotificationModal.tsx +++ b/src/components/SuccessNotificationModal.tsx @@ -10,6 +10,7 @@ import { useNavigate } from 'react-router-dom'; import { useSuccessNotification } from '../store/successNotification'; import { useCurrency } from '../hooks/useCurrency'; import { useTelegramWebApp } from '../hooks/useTelegramWebApp'; +import { useBackButton, useHaptic } from '@/platform'; // Icons const CheckCircleIcon = () => ( @@ -79,7 +80,8 @@ export default function SuccessNotificationModal() { const navigate = useNavigate(); const { isOpen, data, hide } = useSuccessNotification(); const { formatAmount, currencySymbol } = useCurrency(); - const { webApp, safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramWebApp(); + const { safeAreaInset, contentSafeAreaInset, isTelegramWebApp } = useTelegramWebApp(); + const haptic = useHaptic(); const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) @@ -104,18 +106,15 @@ export default function SuccessNotificationModal() { return () => document.removeEventListener('keydown', handleKeyDown); }, [isOpen, handleClose]); - // Telegram back button + // Telegram back button - using platform hook + useBackButton(isOpen ? handleClose : null); + + // Haptic feedback on open useEffect(() => { - if (!isOpen || !webApp?.BackButton) return; - - webApp.BackButton.show(); - webApp.BackButton.onClick(handleClose); - - return () => { - webApp.BackButton.offClick(handleClose); - webApp.BackButton.hide(); - }; - }, [isOpen, webApp, handleClose]); + if (isOpen) { + haptic.notification('success'); + } + }, [isOpen, haptic]); // Scroll lock useEffect(() => { diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx index 5ab21a4..c44a6f7 100644 --- a/src/components/TicketNotificationBell.tsx +++ b/src/components/TicketNotificationBell.tsx @@ -241,7 +241,11 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi {/* Bell button */} + ); + })} +
+ ); +} diff --git a/src/components/admin/SettingsSearch.tsx b/src/components/admin/SettingsSearch.tsx index 4be0c80..033d744 100644 --- a/src/components/admin/SettingsSearch.tsx +++ b/src/components/admin/SettingsSearch.tsx @@ -1,54 +1,222 @@ +import { useState, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { SettingDefinition } from '../../api/adminSettings'; import { SearchIcon, CloseIcon } from './icons'; +import { formatSettingKey } from './utils'; interface SettingsSearchProps { searchQuery: string; setSearchQuery: (query: string) => void; resultsCount?: number; + allSettings?: SettingDefinition[]; + onSelectSetting?: (setting: SettingDefinition) => void; } -export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchProps) { +export function SettingsSearch({ + searchQuery, + setSearchQuery, + allSettings, + onSelectSetting, +}: SettingsSearchProps) { const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const containerRef = useRef(null); + const inputRef = useRef(null); + + // Filter settings for autocomplete + const suggestions = + searchQuery.trim() && allSettings + ? allSettings + .filter((s) => { + const q = searchQuery.toLowerCase().trim(); + if (s.key.toLowerCase().includes(q)) return true; + if (s.name?.toLowerCase().includes(q)) return true; + const formattedKey = formatSettingKey(s.name || s.key); + const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey); + if (translatedName.toLowerCase().includes(q)) return true; + if (s.hint?.description?.toLowerCase().includes(q)) return true; + const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key); + if (categoryLabel.toLowerCase().includes(q)) return true; + return false; + }) + .slice(0, 8) + : []; + + // Close on click outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Reset highlighted index when suggestions change + useEffect(() => { + setHighlightedIndex(0); + }, [suggestions.length, searchQuery]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!isOpen || suggestions.length === 0) return; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + setHighlightedIndex((i) => (i + 1) % suggestions.length); + break; + case 'ArrowUp': + e.preventDefault(); + setHighlightedIndex((i) => (i - 1 + suggestions.length) % suggestions.length); + break; + case 'Enter': + e.preventDefault(); + if (suggestions[highlightedIndex]) { + handleSelect(suggestions[highlightedIndex]); + } + break; + case 'Escape': + setIsOpen(false); + inputRef.current?.blur(); + break; + } + }; + + const handleSelect = (setting: SettingDefinition) => { + setIsOpen(false); + setSearchQuery(setting.name || setting.key); + onSelectSetting?.(setting); + }; + + const getSettingDisplayName = (setting: SettingDefinition) => { + const formattedKey = formatSettingKey(setting.name || setting.key); + return t(`admin.settings.settingNames.${formattedKey}`, formattedKey); + }; return ( - <> - {/* Search - desktop */} -
- setSearchQuery(e.target.value)} - placeholder={t('admin.settings.searchPlaceholder')} - className="w-48 rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none lg:w-64" - /> -
- -
- {searchQuery && ( - - )} +
+ { + setSearchQuery(e.target.value); + setIsOpen(true); + }} + onFocus={() => setIsOpen(true)} + onKeyDown={handleKeyDown} + placeholder={t('admin.settings.searchPlaceholder')} + className="w-48 rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none lg:w-64" + /> +
+
- + {searchQuery && ( + + )} + + {/* Autocomplete dropdown */} + {isOpen && suggestions.length > 0 && ( +
+ {suggestions.map((setting, index) => ( + + ))} +
+ )} +
); } export function SettingsSearchMobile({ searchQuery, setSearchQuery, + allSettings, + onSelectSetting, }: Omit) { const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const containerRef = useRef(null); + + // Filter settings for autocomplete + const suggestions = + searchQuery.trim() && allSettings + ? allSettings + .filter((s) => { + const q = searchQuery.toLowerCase().trim(); + if (s.key.toLowerCase().includes(q)) return true; + if (s.name?.toLowerCase().includes(q)) return true; + const formattedKey = formatSettingKey(s.name || s.key); + const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey); + if (translatedName.toLowerCase().includes(q)) return true; + if (s.hint?.description?.toLowerCase().includes(q)) return true; + const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key); + if (categoryLabel.toLowerCase().includes(q)) return true; + return false; + }) + .slice(0, 8) + : []; + + // Close on click outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + useEffect(() => { + setHighlightedIndex(0); + }, [suggestions.length, searchQuery]); + + const handleSelect = (setting: SettingDefinition) => { + setIsOpen(false); + setSearchQuery(setting.name || setting.key); + onSelectSetting?.(setting); + }; + + const getSettingDisplayName = (setting: SettingDefinition) => { + const formattedKey = formatSettingKey(setting.name || setting.key); + return t(`admin.settings.settingNames.${formattedKey}`, formattedKey); + }; return ( -
+
setSearchQuery(e.target.value)} + onChange={(e) => { + setSearchQuery(e.target.value); + setIsOpen(true); + }} + onFocus={() => setIsOpen(true)} placeholder={t('admin.settings.searchPlaceholder')} className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none" /> @@ -57,12 +225,37 @@ export function SettingsSearchMobile({
{searchQuery && ( )} + + {/* Autocomplete dropdown */} + {isOpen && suggestions.length > 0 && ( +
+ {suggestions.map((setting, index) => ( + + ))} +
+ )}
); } diff --git a/src/components/admin/SettingsSidebar.tsx b/src/components/admin/SettingsSidebar.tsx index b30e2ff..5236c00 100644 --- a/src/components/admin/SettingsSidebar.tsx +++ b/src/components/admin/SettingsSidebar.tsx @@ -1,6 +1,5 @@ import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; -import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index'; +import { AdminBackButton, StarIcon, CloseIcon, MENU_SECTIONS } from './index'; interface SettingsSidebarProps { activeSection: string; @@ -26,12 +25,7 @@ export function SettingsSidebar({ {/* Header */}
- - - +

{t('admin.settings.title')}

+
+ )} + + ); + }, +); + +EmptyState.displayName = 'EmptyState'; diff --git a/src/components/data-display/EmptyState/index.ts b/src/components/data-display/EmptyState/index.ts new file mode 100644 index 0000000..cbf85e5 --- /dev/null +++ b/src/components/data-display/EmptyState/index.ts @@ -0,0 +1 @@ +export { EmptyState, type EmptyStateProps } from './EmptyState'; diff --git a/src/components/data-display/StatCard/StatCard.tsx b/src/components/data-display/StatCard/StatCard.tsx new file mode 100644 index 0000000..0351ad6 --- /dev/null +++ b/src/components/data-display/StatCard/StatCard.tsx @@ -0,0 +1,83 @@ +import { forwardRef, type ReactNode } from 'react'; +import { motion } from 'framer-motion'; +import { cn } from '@/lib/utils'; +import { Card, type CardProps } from '../Card'; +import { slideUp, slideUpTransition } from '@/components/motion/transitions'; + +export interface StatCardProps extends Omit { + label: string; + value: ReactNode; + icon?: ReactNode; + change?: { + value: number; + label?: string; + }; + trend?: 'up' | 'down' | 'neutral'; + loading?: boolean; +} + +export const StatCard = forwardRef( + ( + { label, value, icon, change, trend = 'neutral', loading = false, className, ...props }, + ref, + ) => { + const trendColors = { + up: 'text-success-400', + down: 'text-error-400', + neutral: 'text-dark-400', + }; + + const trendIcon = { + up: '↑', + down: '↓', + neutral: '→', + }; + + return ( + +
+
+ {/* Label */} +

{label}

+ + {/* Value */} + {loading ? ( +
+ ) : ( + + {value} + + )} + + {/* Change indicator */} + {change && !loading && ( +
+ {trendIcon[trend]} + + {change.value > 0 ? '+' : ''} + {change.value}% + + {change.label && {change.label}} +
+ )} +
+ + {/* Icon */} + {icon && ( +
+ {icon} +
+ )} +
+ + ); + }, +); + +StatCard.displayName = 'StatCard'; diff --git a/src/components/data-display/StatCard/index.ts b/src/components/data-display/StatCard/index.ts new file mode 100644 index 0000000..4058481 --- /dev/null +++ b/src/components/data-display/StatCard/index.ts @@ -0,0 +1 @@ +export { StatCard, type StatCardProps } from './StatCard'; diff --git a/src/components/data-display/index.ts b/src/components/data-display/index.ts new file mode 100644 index 0000000..d3e63c3 --- /dev/null +++ b/src/components/data-display/index.ts @@ -0,0 +1,3 @@ +export * from './Card'; +export * from './StatCard'; +export * from './EmptyState'; diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx new file mode 100644 index 0000000..5c64c50 --- /dev/null +++ b/src/components/icons/index.tsx @@ -0,0 +1,675 @@ +import { cn } from '@/lib/utils'; + +interface IconProps { + className?: string; +} + +// Navigation & Layout +export const HomeIcon = ({ className }: IconProps) => ( + + + +); + +export const BackIcon = ({ className }: IconProps) => ( + + + +); + +export const MenuIcon = ({ className }: IconProps) => ( + + + +); + +export const CloseIcon = ({ className }: IconProps) => ( + + + +); + +export const ChevronDownIcon = ({ className }: IconProps) => ( + + + +); + +export const ArrowRightIcon = ({ className }: IconProps) => ( + + + +); + +// Actions +export const SearchIcon = ({ className }: IconProps) => ( + + + +); + +export const PlusIcon = ({ className }: IconProps) => ( + + + +); + +export const EditIcon = ({ className }: IconProps) => ( + + + +); + +export const PencilIcon = ({ className }: IconProps) => ( + + + +); + +export const TrashIcon = ({ className }: IconProps) => ( + + + +); + +export const UploadIcon = ({ className }: IconProps) => ( + + + +); + +export const DownloadIcon = ({ className }: IconProps) => ( + + + +); + +export const RefreshIcon = ({ + className, + spinning = false, +}: IconProps & { spinning?: boolean }) => ( + + + +); + +export const SyncIcon = ({ className }: IconProps) => ( + + + +); + +// Status +export const CheckIcon = ({ className }: IconProps) => ( + + + +); + +export const XIcon = ({ className }: IconProps) => ( + + + +); + +export const LockIcon = ({ className }: IconProps) => ( + + + +); + +export const InfoIcon = ({ className }: IconProps) => ( + + + +); + +// User & People +export const UserIcon = ({ className }: IconProps) => ( + + + +); + +export const UsersIcon = ({ className }: IconProps) => ( + + + +); + +export const LogoutIcon = ({ className }: IconProps) => ( + + + +); + +// Theme +export const SunIcon = ({ className }: IconProps) => ( + + + +); + +export const MoonIcon = ({ className }: IconProps) => ( + + + +); + +export const PaletteIcon = ({ className }: IconProps) => ( + + + +); + +// Features & Content +export const SubscriptionIcon = ({ className }: IconProps) => ( + + + +); + +export const WalletIcon = ({ className }: IconProps) => ( + + + +); + +export const ChatIcon = ({ className }: IconProps) => ( + + + +); + +export const GiftIcon = ({ className }: IconProps) => ( + + + +); + +export const ClockIcon = ({ className }: IconProps) => ( + + + +); + +export const StarIcon = ({ className, filled }: IconProps & { filled?: boolean }) => ( + + + +); + +export const GamepadIcon = ({ className }: IconProps) => ( + + + +); + +export const ClipboardIcon = ({ className }: IconProps) => ( + + + +); + +export const CogIcon = ({ className }: IconProps) => ( + + + + +); + +export const WheelIcon = ({ className }: IconProps) => ( + + + +); + +export const ServerIcon = ({ className }: IconProps) => ( + + + +); + +export const CampaignIcon = ({ className }: IconProps) => ( + + + +); + +export const RemnawaveIcon = ({ className }: IconProps) => ( + + + +); + +export const ChartIcon = ({ className }: IconProps) => ( + + + +); + +export const GlobeIcon = ({ className }: IconProps) => ( + + + +); + +export const PlayIcon = ({ className }: IconProps) => ( + + + +); + +export const StopIcon = ({ className }: IconProps) => ( + + + +); + +export const ArrowPathIcon = ({ className }: IconProps) => ( + + + +); diff --git a/src/components/layout/AppShell/AppHeader.tsx b/src/components/layout/AppShell/AppHeader.tsx new file mode 100644 index 0000000..002b32d --- /dev/null +++ b/src/components/layout/AppShell/AppHeader.tsx @@ -0,0 +1,410 @@ +import { Link, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; +import { useState, useEffect } from 'react'; + +import { useAuthStore } from '@/store/auth'; +import { useTheme } from '@/hooks/useTheme'; +import { usePlatform } from '@/platform'; +import { + brandingApi, + getCachedBranding, + setCachedBranding, + preloadLogo, + isLogoPreloaded, +} from '@/api/branding'; +import { themeColorsApi } from '@/api/themeColors'; +import { cn } from '@/lib/utils'; + +import LanguageSwitcher from '@/components/LanguageSwitcher'; +import TicketNotificationBell from '@/components/TicketNotificationBell'; + +// Icons +import { + HomeIcon, + SubscriptionIcon, + WalletIcon, + UsersIcon, + ChatIcon, + UserIcon, + LogoutIcon, + GamepadIcon, + ClipboardIcon, + InfoIcon, + CogIcon, + WheelIcon, + MenuIcon, + CloseIcon, + SunIcon, + MoonIcon, + SearchIcon, +} from './icons'; + +const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'; +const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V'; + +import type { TelegramPlatform } from '@/hooks/useTelegramSDK'; + +interface AppHeaderProps { + mobileMenuOpen: boolean; + setMobileMenuOpen: (open: boolean) => void; + onCommandPaletteOpen: () => void; + headerHeight: number; + isFullscreen: boolean; + safeAreaInset: { top: number; bottom: number; left: number; right: number }; + contentSafeAreaInset: { top: number; bottom: number; left: number; right: number }; + telegramPlatform?: TelegramPlatform; + wheelEnabled?: boolean; + referralEnabled?: boolean; + hasContests?: boolean; + hasPolls?: boolean; +} + +export function AppHeader({ + mobileMenuOpen, + setMobileMenuOpen, + onCommandPaletteOpen, + headerHeight, + isFullscreen, + safeAreaInset, + contentSafeAreaInset, + telegramPlatform, + wheelEnabled, + referralEnabled, + hasContests, + hasPolls, +}: AppHeaderProps) { + const { t } = useTranslation(); + const location = useLocation(); + const { user, logout, isAdmin } = useAuthStore(); + const { toggleTheme, isDark } = useTheme(); + const { haptic, platform } = usePlatform(); + const [userPhotoUrl, setUserPhotoUrl] = useState(null); + const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded()); + + // 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, + refetchOnWindowFocus: true, + retry: 1, + }); + + 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; + + // Theme toggle visibility + const { data: enabledThemes } = useQuery({ + queryKey: ['enabled-themes'], + queryFn: themeColorsApi.getEnabledThemes, + staleTime: 1000 * 60 * 5, + }); + const canToggle = enabledThemes?.dark && enabledThemes?.light; + + // Get user photo from Telegram + useEffect(() => { + try { + const tg = ( + window as unknown as { + Telegram?: { WebApp?: { initDataUnsafe?: { user?: { photo_url?: string } } } }; + } + ).Telegram?.WebApp; + const photoUrl = tg?.initDataUnsafe?.user?.photo_url; + if (photoUrl) { + setUserPhotoUrl(photoUrl); + } + } catch (e) { + console.warn('Failed to get Telegram user photo:', e); + } + }, []); + + // Lock scroll when menu is open (works in iframe/Telegram Mini App) + useEffect(() => { + if (!mobileMenuOpen) return; + + const preventDefault = (e: TouchEvent) => { + // Allow scrolling inside menu content + const target = e.target as HTMLElement; + if (target.closest('.mobile-menu-content')) return; + e.preventDefault(); + }; + + document.addEventListener('touchmove', preventDefault, { passive: false }); + document.body.style.overflow = 'hidden'; + document.documentElement.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('touchmove', preventDefault); + document.body.style.overflow = ''; + document.documentElement.style.overflow = ''; + }; + }, [mobileMenuOpen]); + + const isActive = (path: string) => location.pathname === path; + const isAdminActive = () => location.pathname.startsWith('/admin'); + + const navItems = [ + { path: '/', label: t('nav.dashboard'), icon: HomeIcon }, + { path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon }, + { path: '/balance', label: t('nav.balance'), icon: WalletIcon }, + ...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []), + { path: '/support', label: t('nav.support'), icon: ChatIcon }, + ...(hasContests ? [{ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }] : []), + ...(hasPolls ? [{ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }] : []), + ...(wheelEnabled ? [{ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }] : []), + { path: '/info', label: t('nav.info'), icon: InfoIcon }, + ]; + + return ( + <> + {/* Header - only on mobile */} +
+
mobileMenuOpen && setMobileMenuOpen(false)} + > +
+ {/* Logo */} + setMobileMenuOpen(false)} + className={cn('flex flex-shrink-0 items-center gap-2.5', !appName && 'mr-4')} + > +
+ + {logoLetter} + + {hasCustomLogo && logoUrl && ( + {appName setLogoLoaded(true)} + /> + )} +
+ {appName && ( + + {appName} + + )} + + + {/* Right side */} +
+ {/* Command palette trigger (web only) */} + {platform !== 'telegram' && ( + + )} + + {/* Theme toggle */} + {canToggle && ( + + )} + +
setMobileMenuOpen(false)}> + +
+
setMobileMenuOpen(false)}> + +
+ + {/* Mobile menu button */} + +
+
+
+
+ + {/* Mobile menu overlay */} + {mobileMenuOpen && ( +
+ {/* Backdrop */} +
setMobileMenuOpen(false)} /> + + {/* Menu content */} +
+
+ {/* User info */} +
+
+ {userPhotoUrl ? ( + Avatar { + e.currentTarget.style.display = 'none'; + e.currentTarget.nextElementSibling?.classList.remove('hidden'); + }} + /> + ) : null} +
+ +
+
+
+ {user?.first_name || user?.username} +
+
+ @{user?.username || `ID: ${user?.telegram_id}`} +
+
+
+
+ + {/* Nav items */} + +
+
+
+ )} + + ); +} diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx new file mode 100644 index 0000000..bb8f401 --- /dev/null +++ b/src/components/layout/AppShell/AppShell.tsx @@ -0,0 +1,512 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import { useLocation, useNavigate, Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import { useAuthStore } from '@/store/auth'; +import { useBackButton, useHaptic } from '@/platform'; +import { useTelegramSDK } from '@/hooks/useTelegramSDK'; +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 { cn } from '@/lib/utils'; + +import WebSocketNotifications from '@/components/WebSocketNotifications'; +import SuccessNotificationModal from '@/components/SuccessNotificationModal'; +import LanguageSwitcher from '@/components/LanguageSwitcher'; +import TicketNotificationBell from '@/components/TicketNotificationBell'; + +import { MobileBottomNav } from './MobileBottomNav'; +import { AppHeader } from './AppHeader'; +import { Aurora } from './Aurora'; + +// Desktop nav icons +const HomeIcon = ({ className }: { className?: string }) => ( + + + +); + +const CreditCardIcon = ({ className }: { className?: string }) => ( + + + +); + +const ChatIcon = ({ className }: { className?: string }) => ( + + + +); + +const UserIcon = ({ className }: { className?: string }) => ( + + + +); + +const UsersIcon = ({ className }: { className?: string }) => ( + + + +); + +const ShieldIcon = ({ className }: { className?: string }) => ( + + + +); + +const LogoutIcon = ({ 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; +} + +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 haptic = useHaptic(); + + // Only apply fullscreen UI adjustments on mobile Telegram (iOS/Android) + const isMobileFullscreen = isFullscreen && isMobile; + + 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) => { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + setIsKeyboardOpen(true); + } + }; + + const handleFocusOut = (e: FocusEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement | null; + if ( + !relatedTarget || + (relatedTarget.tagName !== 'INPUT' && + relatedTarget.tagName !== 'TEXTAREA' && + !relatedTarget.isContentEditable) + ) { + setIsKeyboardOpen(false); + } + }; + + document.addEventListener('focusin', handleFocusIn); + document.addEventListener('focusout', handleFocusOut); + + return () => { + document.removeEventListener('focusin', handleFocusIn); + document.removeEventListener('focusout', handleFocusOut); + }; + }, []); + + // Desktop navigation items + const desktopNavItems = [ + { path: '/', label: t('nav.dashboard'), icon: HomeIcon }, + { path: '/balance', label: t('nav.balance'), icon: CreditCardIcon }, + { path: '/support', label: t('nav.support'), icon: ChatIcon }, + { path: '/profile', label: t('nav.profile'), icon: UserIcon }, + ]; + + const isActive = (path: string) => { + if (path === '/') return location.pathname === '/'; + return location.pathname.startsWith(path); + }; + + const handleNavClick = () => { + haptic.impact('light'); + }; + + // 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 headerHeight = isMobileFullscreen + ? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + telegramHeaderHeight + : 64; + + return ( +
+ {/* Animated background */} + + + {/* Global components */} + + + + {/* Desktop Header */} +
+
+ {/* Logo */} + +
+ + {logoLetter} + + {hasCustomLogo && logoUrl && ( + {appName + )} +
+ {appName} + + + {/* Center Navigation */} + + + {/* Right side actions */} +
+ + + +
+
+
+ + {/* Mobile Header */} + {}} + headerHeight={headerHeight} + isFullscreen={isMobileFullscreen} + safeAreaInset={safeAreaInset} + contentSafeAreaInset={contentSafeAreaInset} + telegramPlatform={platform} + wheelEnabled={wheelConfig?.is_enabled} + referralEnabled={referralTerms?.is_enabled} + hasContests={(contestsCount?.count ?? 0) > 0} + hasPolls={(pollsCount?.count ?? 0) > 0} + /> + + {/* Desktop spacer */} +
+ + {/* Mobile spacer */} +
+ + {/* Main content */} +
{children}
+ + {/* Mobile Bottom Navigation */} + +
+ ); +} diff --git a/src/components/layout/AppShell/Aurora.tsx b/src/components/layout/AppShell/Aurora.tsx new file mode 100644 index 0000000..df056e2 --- /dev/null +++ b/src/components/layout/AppShell/Aurora.tsx @@ -0,0 +1,263 @@ +import { useEffect, useRef } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Renderer, Program, Mesh, Color, Triangle } from 'ogl'; +import { brandingApi } from '@/api/branding'; +import { ThemeSettings, DEFAULT_THEME_COLORS } from '@/types/theme'; + +const VERT = /* glsl */ `#version 300 es + in vec2 position; + void main() { + gl_Position = vec4(position, 0.0, 1.0); + } +`; + +const FRAG = /* glsl */ `#version 300 es + precision highp float; + + uniform float uTime; + uniform float uAmplitude; + uniform vec3 uColorStops[3]; + uniform vec2 uResolution; + uniform float uBlend; + + out vec4 fragColor; + + vec3 permute(vec3 x) { return mod(((x * 34.0) + 1.0) * x, 289.0); } + + float snoise(vec2 v) { + const vec4 C = vec4(0.211324865405187, 0.366025403784439, + -0.577350269189626, 0.024390243902439); + vec2 i = floor(v + dot(v, C.yy)); + vec2 x0 = v - i + dot(i, C.xx); + vec2 i1; + i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0); + vec4 x12 = x0.xyxy + C.xxzz; + x12.xy -= i1; + i = mod(i, 289.0); + + vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0)) + + i.x + vec3(0.0, i1.x, 1.0)); + + vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), + dot(x12.zw, x12.zw)), 0.0); + m = m * m; + m = m * m; + + vec3 x = 2.0 * fract(p * C.www) - 1.0; + vec3 h = abs(x) - 0.5; + vec3 ox = floor(x + 0.5); + vec3 a0 = x - ox; + + m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h); + + vec3 g; + g.x = a0.x * x0.x + h.x * x0.y; + g.yz = a0.yz * x12.xz + h.yz * x12.yw; + return 130.0 * dot(m, g); + } + + struct ColorStop { + vec3 color; + float position; + }; + + #define COLOR_RAMP(colors, factor, finalColor) { \ + int index = 0; \ + for (int i = 0; i < colors.length() - 1; i++) { \ + ColorStop currentColor = cyclingColors[i]; \ + bool isInBetween = cyclingColors[i].position <= factor; \ + index = isInBetween ? i : index; \ + } \ + ColorStop currentColor = cyclingColors[index]; \ + ColorStop nextColor = cyclingColors[index + 1]; \ + float range = cyclingColors[index + 1].position - currentColor.position; \ + float lerpFactor = (factor - currentColor.position) / range; \ + finalColor = mix(currentColor.color, nextColor.color, lerpFactor); \ + } + + void main() { + vec2 uv = gl_FragCoord.xy / uResolution; + + ColorStop cyclingColors[3]; + cyclingColors[0] = ColorStop(uColorStops[0], 0.0); + cyclingColors[1] = ColorStop(uColorStops[1], 0.5); + cyclingColors[2] = ColorStop(uColorStops[2], 1.0); + + float noiseValue = snoise(uv * uAmplitude + uTime) * 0.5 + 0.5; + + vec3 rampColor; + COLOR_RAMP(cyclingColors, noiseValue, rampColor); + + fragColor = vec4(rampColor, uBlend); + } +`; + +function hexToRgb(hex: string): [number, number, number] { + hex = hex.replace('#', ''); + if (hex.length === 3) { + hex = hex + .split('') + .map((c) => c + c) + .join(''); + } + const r = parseInt(hex.substring(0, 2), 16) / 255; + const g = parseInt(hex.substring(2, 4), 16) / 255; + const b = parseInt(hex.substring(4, 6), 16) / 255; + return [r, g, b]; +} + +function generateColorStops(accent: string, background: string): string[] { + const [ar, ag, ab] = hexToRgb(accent); + + // Color 1: Dark background + const color1 = background; + + // Color 2: Accent at 40% intensity + const midR = Math.round(ar * 255 * 0.4); + const midG = Math.round(ag * 255 * 0.4); + const midB = Math.round(ab * 255 * 0.4); + const color2 = `#${midR.toString(16).padStart(2, '0')}${midG.toString(16).padStart(2, '0')}${midB.toString(16).padStart(2, '0')}`; + + // Color 3: Accent at 70% intensity + const brightR = Math.round(ar * 255 * 0.7); + const brightG = Math.round(ag * 255 * 0.7); + const brightB = Math.round(ab * 255 * 0.7); + const color3 = `#${brightR.toString(16).padStart(2, '0')}${brightG.toString(16).padStart(2, '0')}${brightB.toString(16).padStart(2, '0')}`; + + return [color1, color2, color3]; +} + +export function Aurora() { + const containerRef = useRef(null); + const animationFrameRef = useRef(0); + const rendererRef = useRef(null); + const programRef = useRef(null); + + const queryClient = useQueryClient(); + + // Fetch animation setting + const { data: animationSetting } = useQuery({ + queryKey: ['animation-enabled'], + queryFn: brandingApi.getAnimationEnabled, + staleTime: 60000, + }); + + const isEnabled = animationSetting?.enabled ?? false; + + // Get theme colors from cache (already fetched by ThemeColorsProvider) + const themeColors = + queryClient.getQueryData(['theme-colors']) || DEFAULT_THEME_COLORS; + + useEffect(() => { + if (!isEnabled || !containerRef.current) return; + + // Check for reduced motion preference + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (prefersReducedMotion) return; + + const container = containerRef.current; + const renderer = new Renderer({ + alpha: true, + antialias: true, + powerPreference: 'low-power', + }); + rendererRef.current = renderer; + const gl = renderer.gl; + gl.clearColor(0, 0, 0, 0); + container.appendChild(gl.canvas); + + const geometry = new Triangle(gl); + + const colorStops = generateColorStops(themeColors.accent, themeColors.darkBackground); + const colorStopsArray = colorStops + .map((hex) => { + const c = new Color(hex); + return [c.r, c.g, c.b]; + }) + .flat(); + + const program = new Program(gl, { + vertex: VERT, + fragment: FRAG, + uniforms: { + uTime: { value: 0 }, + uAmplitude: { value: 1.0 }, + uColorStops: { value: colorStopsArray }, + uResolution: { value: [container.offsetWidth, container.offsetHeight] }, + uBlend: { value: 1.0 }, + }, + }); + programRef.current = program; + + const mesh = new Mesh(gl, { geometry, program }); + + function resize() { + if (!containerRef.current || !rendererRef.current || !programRef.current) return; + const w = containerRef.current.offsetWidth; + const h = containerRef.current.offsetHeight; + rendererRef.current.setSize(w, h); + programRef.current.uniforms.uResolution.value = [w, h]; + } + + window.addEventListener('resize', resize); + resize(); + + let lastTime = 0; + const targetFPS = 30; + const frameInterval = 1000 / targetFPS; + const speed = 0.3; // Slow and smooth + + function animate(currentTime: number) { + animationFrameRef.current = requestAnimationFrame(animate); + + const delta = currentTime - lastTime; + if (delta < frameInterval) return; + + lastTime = currentTime - (delta % frameInterval); + + if (programRef.current && rendererRef.current) { + programRef.current.uniforms.uTime.value += speed * 0.01; + rendererRef.current.render({ scene: mesh }); + } + } + animationFrameRef.current = requestAnimationFrame(animate); + + return () => { + window.removeEventListener('resize', resize); + cancelAnimationFrame(animationFrameRef.current); + if (rendererRef.current && container.contains(rendererRef.current.gl.canvas)) { + container.removeChild(rendererRef.current.gl.canvas); + } + rendererRef.current = null; + programRef.current = null; + }; + }, [isEnabled, themeColors.accent, themeColors.darkBackground]); + + if (!isEnabled) { + return null; + } + + // Blur overlay color from accent (very subtle) + const [r, g, b] = hexToRgb(themeColors.accent); + const blurColor = `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, 0.03)`; + + return ( + <> + {/* WebGL Aurora canvas */} +
+ {/* Blur overlay */} +
+ + ); +} diff --git a/src/components/layout/AppShell/DesktopSidebar.tsx b/src/components/layout/AppShell/DesktopSidebar.tsx new file mode 100644 index 0000000..ecebd2f --- /dev/null +++ b/src/components/layout/AppShell/DesktopSidebar.tsx @@ -0,0 +1,210 @@ +import { Link, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { motion } from 'framer-motion'; +import { useQuery } from '@tanstack/react-query'; + +import { useAuthStore } from '@/store/auth'; +import { + brandingApi, + getCachedBranding, + setCachedBranding, + preloadLogo, + isLogoPreloaded, +} from '@/api/branding'; +import { cn } from '@/lib/utils'; +import { usePlatform } from '@/platform'; +// Icons +import { + HomeIcon, + SubscriptionIcon, + WalletIcon, + UsersIcon, + ChatIcon, + UserIcon, + LogoutIcon, + GamepadIcon, + ClipboardIcon, + InfoIcon, + CogIcon, + WheelIcon, +} from './icons'; + +const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'; +const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V'; + +interface DesktopSidebarProps { + isAdmin?: boolean; + wheelEnabled?: boolean; + referralEnabled?: boolean; + hasContests?: boolean; + hasPolls?: boolean; +} + +export function DesktopSidebar({ + isAdmin, + wheelEnabled, + referralEnabled, + hasContests, + hasPolls, +}: DesktopSidebarProps) { + const { t } = useTranslation(); + const location = useLocation(); + const { user, logout } = useAuthStore(); + const { haptic } = usePlatform(); + + // 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, + refetchOnWindowFocus: true, + retry: 1, + }); + + 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; + + const isActive = (path: string) => location.pathname === path; + const isAdminActive = () => location.pathname.startsWith('/admin'); + + const navItems = [ + { path: '/', label: t('nav.dashboard'), icon: HomeIcon }, + { path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon }, + { path: '/balance', label: t('nav.balance'), icon: WalletIcon }, + ...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []), + { path: '/support', label: t('nav.support'), icon: ChatIcon }, + ...(hasContests ? [{ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }] : []), + ...(hasPolls ? [{ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }] : []), + ...(wheelEnabled ? [{ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }] : []), + { path: '/info', label: t('nav.info'), icon: InfoIcon }, + ]; + + const handleNavClick = () => { + haptic.impact('light'); + }; + + return ( + + ); +} diff --git a/src/components/layout/AppShell/MobileBottomNav.tsx b/src/components/layout/AppShell/MobileBottomNav.tsx new file mode 100644 index 0000000..a51cc30 --- /dev/null +++ b/src/components/layout/AppShell/MobileBottomNav.tsx @@ -0,0 +1,86 @@ +import { Link, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { motion } from 'framer-motion'; + +import { cn } from '@/lib/utils'; +import { usePlatform } from '@/platform'; + +// Icons +import { HomeIcon, SubscriptionIcon, WalletIcon, UsersIcon, ChatIcon, WheelIcon } from './icons'; + +interface MobileBottomNavProps { + isKeyboardOpen: boolean; + referralEnabled?: boolean; + wheelEnabled?: boolean; +} + +export function MobileBottomNav({ + isKeyboardOpen, + referralEnabled, + wheelEnabled, +}: MobileBottomNavProps) { + const { t } = useTranslation(); + const location = useLocation(); + const { haptic } = usePlatform(); + + const isActive = (path: string) => location.pathname === path; + + // Core navigation items for bottom bar + // When wheel is enabled, it replaces Support in the bottom nav (Support is still accessible via hamburger menu) + const coreItems = [ + { path: '/', label: t('nav.dashboard'), icon: HomeIcon }, + { path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon }, + { path: '/balance', label: t('nav.balance'), icon: WalletIcon }, + ...(referralEnabled ? [{ path: '/referral', label: t('nav.referral'), icon: UsersIcon }] : []), + ...(wheelEnabled + ? [{ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }] + : [{ path: '/support', label: t('nav.support'), icon: ChatIcon }]), + ]; + + const handleNavClick = () => { + haptic.impact('light'); + }; + + return ( + + ); +} diff --git a/src/components/layout/AppShell/MovingGradient.tsx b/src/components/layout/AppShell/MovingGradient.tsx new file mode 100644 index 0000000..d53ec30 --- /dev/null +++ b/src/components/layout/AppShell/MovingGradient.tsx @@ -0,0 +1,57 @@ +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/layout/AppShell/icons.tsx b/src/components/layout/AppShell/icons.tsx new file mode 100644 index 0000000..d8678c9 --- /dev/null +++ b/src/components/layout/AppShell/icons.tsx @@ -0,0 +1,24 @@ +// Re-export from centralized icons +export { + HomeIcon, + SubscriptionIcon, + WalletIcon, + UsersIcon, + ChatIcon, + UserIcon, + LogoutIcon, + SunIcon, + MoonIcon, + MenuIcon, + CloseIcon, + GamepadIcon, + ClipboardIcon, + InfoIcon, + CogIcon, + WheelIcon, + SearchIcon, + PlusIcon, + ArrowRightIcon, + DownloadIcon, + PaletteIcon, +} from '../../icons'; diff --git a/src/components/layout/AppShell/index.ts b/src/components/layout/AppShell/index.ts new file mode 100644 index 0000000..aeb68cd --- /dev/null +++ b/src/components/layout/AppShell/index.ts @@ -0,0 +1,5 @@ +export { AppShell } from './AppShell'; +export { DesktopSidebar } from './DesktopSidebar'; +export { MobileBottomNav } from './MobileBottomNav'; +export { AppHeader } from './AppHeader'; +export { Aurora } from './Aurora'; diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 59d2aee..266b7cb 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -1,820 +1,17 @@ -import { Link, useLocation } from 'react-router-dom'; -import { useTranslation } from 'react-i18next'; -import { useState, useEffect, useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { useAuthStore } from '../../store/auth'; -import LanguageSwitcher from '../LanguageSwitcher'; -import PromoDiscountBadge from '../PromoDiscountBadge'; -import TicketNotificationBell from '../TicketNotificationBell'; -import WebSocketNotifications from '../WebSocketNotifications'; -import SuccessNotificationModal from '../SuccessNotificationModal'; -import AnimatedBackground from '../AnimatedBackground'; -import { contestsApi } from '../../api/contests'; -import { pollsApi } from '../../api/polls'; -import { - brandingApi, - getCachedBranding, - setCachedBranding, - preloadLogo, - isLogoPreloaded, -} from '../../api/branding'; -import { wheelApi } from '../../api/wheel'; -import { themeColorsApi } from '../../api/themeColors'; -import { promoApi } from '../../api/promo'; -import { referralApi } from '../../api/referral'; -import { useTheme } from '../../hooks/useTheme'; -import { useTelegramWebApp } from '../../hooks/useTelegramWebApp'; -import { usePullToRefresh } from '../../hooks/usePullToRefresh'; - -// Fallback branding from environment variables -const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'; -const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V'; +import { AppShell } from './AppShell'; interface LayoutProps { children: React.ReactNode; } -// Icons as simple SVG components -const HomeIcon = () => ( - - - -); - -const SubscriptionIcon = () => ( - - - -); - -const WalletIcon = () => ( - - - -); - -const UsersIcon = () => ( - - - -); - -const ChatIcon = () => ( - - - -); - -const UserIcon = () => ( - - - -); - -const LogoutIcon = () => ( - - - -); - -// Theme toggle icons -const SunIcon = () => ( - - - -); - -const MoonIcon = () => ( - - - -); - -const MenuIcon = () => ( - - - -); - -const CloseIcon = () => ( - - - -); - -const GamepadIcon = () => ( - - - -); - -const ClipboardIcon = () => ( - - - -); - -const InfoIcon = () => ( - - - -); - -const CogIcon = () => ( - - - - -); - -const WheelIcon = () => ( - - - -); - +/** + * Main layout component that wraps all pages. + * Uses the new AppShell system with: + * - Desktop sidebar navigation + * - Mobile bottom navigation + * - Command palette (⌘K) + * - Platform-aware features (Telegram integration) + */ export default function Layout({ children }: LayoutProps) { - const { t } = useTranslation(); - const location = useLocation(); - const { user, logout, isAdmin, isAuthenticated } = useAuthStore(); - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const [isKeyboardOpen, setIsKeyboardOpen] = useState(false); - const { toggleTheme, isDark } = useTheme(); - const [userPhotoUrl, setUserPhotoUrl] = useState(null); - const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp(); - - // Pull to refresh (disabled when mobile menu is open) - const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({ - disabled: mobileMenuOpen, - threshold: 80, - }); - - // Fetch enabled themes from API - same source of truth as AdminSettings - const { data: enabledThemes } = useQuery({ - queryKey: ['enabled-themes'], - queryFn: themeColorsApi.getEnabledThemes, - staleTime: 1000 * 60 * 5, // 5 minutes - }); - - // Only show theme toggle if both themes are enabled - const canToggle = enabledThemes?.dark && enabledThemes?.light; - - // Get user photo from Telegram WebApp - useEffect(() => { - try { - const tg = ( - window as unknown as { - Telegram?: { WebApp?: { initDataUnsafe?: { user?: { photo_url?: string } } } }; - } - ).Telegram?.WebApp; - const photoUrl = tg?.initDataUnsafe?.user?.photo_url; - if (photoUrl) { - setUserPhotoUrl(photoUrl); - } - } catch (e) { - console.warn('Failed to get Telegram user photo:', e); - } - }, []); - - // Lock body scroll when mobile menu is open (cross-platform) - // Note: We avoid using body position:fixed with top:-scrollY as it causes issues - // in Telegram Mini App where the menu disappears when opened from scrolled position - useEffect(() => { - if (!mobileMenuOpen) return; - - const body = document.body; - const html = document.documentElement; - - // Save original styles - const originalStyles = { - bodyOverflow: body.style.overflow, - htmlOverflow: html.style.overflow, - }; - - // Lock scroll - simple approach without body position manipulation - body.style.overflow = 'hidden'; - html.style.overflow = 'hidden'; - - // Prevent touchmove on body (critical for mobile, especially Telegram Mini App) - const preventScroll = (e: TouchEvent) => { - const target = e.target as HTMLElement; - // Allow scroll inside menu content - if (target.closest('.mobile-menu-content')) return; - e.preventDefault(); - }; - document.addEventListener('touchmove', preventScroll, { passive: false }); - - // Also prevent wheel scroll on desktop - const preventWheel = (e: WheelEvent) => { - const target = e.target as HTMLElement; - if (target.closest('.mobile-menu-content')) return; - e.preventDefault(); - }; - document.addEventListener('wheel', preventWheel, { passive: false }); - - return () => { - // Restore original styles - body.style.overflow = originalStyles.bodyOverflow; - html.style.overflow = originalStyles.htmlOverflow; - - // Remove listeners - document.removeEventListener('touchmove', preventScroll); - document.removeEventListener('wheel', preventWheel); - }; - }, [mobileMenuOpen]); - - // Detect virtual keyboard by tracking focus on input elements - useEffect(() => { - const handleFocusIn = (e: FocusEvent) => { - const target = e.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { - setIsKeyboardOpen(true); - } - }; - - const handleFocusOut = (e: FocusEvent) => { - const relatedTarget = e.relatedTarget as HTMLElement | null; - // Only close if not focusing another input - if ( - !relatedTarget || - (relatedTarget.tagName !== 'INPUT' && - relatedTarget.tagName !== 'TEXTAREA' && - !relatedTarget.isContentEditable) - ) { - setIsKeyboardOpen(false); - } - }; - - document.addEventListener('focusin', handleFocusIn); - document.addEventListener('focusout', handleFocusOut); - - return () => { - document.removeEventListener('focusin', handleFocusIn); - document.removeEventListener('focusout', handleFocusOut); - }; - }, []); - - // State to track if logo image has loaded - start with true if already preloaded - const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded()); - - // Fetch branding settings with localStorage cache for instant load - const { data: branding } = useQuery({ - queryKey: ['branding'], - queryFn: async () => { - const data = await brandingApi.getBranding(); - setCachedBranding(data); // Update cache - // Preload logo in background - preloadLogo(data); - return data; - }, - initialData: getCachedBranding() ?? undefined, // Use cached data immediately - staleTime: 60000, // 1 minute - refetchOnWindowFocus: true, - retry: 1, - }); - - // Computed branding values - use fallback only if no branding and no cache - const appName = branding ? branding.name : FALLBACK_NAME; // Empty string is valid (logo-only mode) - 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'; // Fallback title if name is empty - }, [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.getElementsByTagName('head')[0].appendChild(link); - }, [logoUrl]); - - // Fetch contests and polls counts to determine if they should be shown - const { data: contestsCount } = useQuery({ - queryKey: ['contests-count'], - queryFn: contestsApi.getCount, - enabled: isAuthenticated, - staleTime: 60000, // 1 minute - retry: false, - }); - - const { data: pollsCount } = useQuery({ - queryKey: ['polls-count'], - queryFn: pollsApi.getCount, - enabled: isAuthenticated, - staleTime: 60000, // 1 minute - retry: false, - }); - - // Fetch wheel config to check if enabled - const { data: wheelConfig } = useQuery({ - queryKey: ['wheel-config'], - queryFn: wheelApi.getConfig, - enabled: isAuthenticated, - staleTime: 60000, // 1 minute - retry: false, - }); - - // Fetch referral terms to check if enabled - const { data: referralTerms } = useQuery({ - queryKey: ['referral-terms'], - queryFn: referralApi.getReferralTerms, - enabled: isAuthenticated, - staleTime: 60000, // 1 minute - retry: false, - }); - - // Fetch active discount to determine mobile layout - const { data: activeDiscount } = useQuery({ - queryKey: ['active-discount'], - queryFn: promoApi.getActiveDiscount, - enabled: isAuthenticated, - staleTime: 30000, - }); - - // Check if promo is active (to hide language switcher on mobile) - const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent; - - const navItems = useMemo(() => { - const items = [ - { path: '/', label: t('nav.dashboard'), icon: HomeIcon }, - { path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon }, - { path: '/balance', label: t('nav.balance'), icon: WalletIcon }, - ]; - - // Only show referral if program is enabled - if (referralTerms?.is_enabled) { - items.push({ path: '/referral', label: t('nav.referral'), icon: UsersIcon }); - } - - items.push({ path: '/support', label: t('nav.support'), icon: ChatIcon }); - - // Only show contests if there are available contests - if (contestsCount && contestsCount.count > 0) { - items.push({ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }); - } - - // Only show polls if there are available polls - if (pollsCount && pollsCount.count > 0) { - items.push({ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }); - } - - items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon }); - - return items; - }, [t, contestsCount, pollsCount, referralTerms]); - - // Separate navItems for desktop that includes wheel (if enabled) - const desktopNavItems = useMemo(() => { - const items = [...navItems]; - // Add wheel before info if enabled - if (wheelConfig?.is_enabled) { - const infoIndex = items.findIndex((item) => item.path === '/info'); - if (infoIndex !== -1) { - items.splice(infoIndex, 0, { path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }); - } else { - items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }); - } - } - return items; - }, [navItems, wheelConfig, t]); - - const adminNavItems = [{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon }]; - - const isActive = (path: string) => location.pathname === path; - const isAdminActive = () => location.pathname.startsWith('/admin'); - - return ( -
- {/* Global WebSocket notifications handler */} - - - {/* Global success notification modal */} - - - {/* Animated Background */} - - - {/* Pull to refresh indicator */} - {(isPulling || isRefreshing) && ( -
-
- - - -
-
- )} - - {/* Header */} -
-
mobileMenuOpen && setMobileMenuOpen(false)} - > -
- {/* Logo */} - setMobileMenuOpen(false)} - className={`flex flex-shrink-0 items-center gap-2.5 ${!appName ? 'lg:mr-4' : ''}`} - > -
- {/* Always show letter as fallback */} - - {logoLetter} - - {/* Logo image with smooth fade-in */} - {hasCustomLogo && logoUrl && ( - {appName setLogoLoaded(true)} - /> - )} -
- {appName && ( - - {appName} - - )} - - - {/* Desktop Navigation */} - - - {/* Right side */} -
- {/* Theme toggle button - only show if both themes are enabled */} - {canToggle && ( - - )} - -
setMobileMenuOpen(false)}> - -
-
setMobileMenuOpen(false)}> - -
- {/* Hide language switcher on mobile when promo is active */} -
setMobileMenuOpen(false)} - > - -
- - {/* Profile - Desktop */} -
- -
- -
- - {user?.first_name || user?.username || `#${user?.telegram_id}`} - - - -
- - {/* Mobile menu button */} - -
-
-
-
- - {/* Spacer for fixed header - matches header height */} - {isFullscreen ? ( -
- ) : ( -
- )} - - {/* Mobile menu - fixed overlay below header */} - {mobileMenuOpen && ( -
- {/* Backdrop */} -
setMobileMenuOpen(false)} - /> - - {/* Menu content */} -
-
- {/* User info */} -
-
- {userPhotoUrl ? ( - Avatar { - e.currentTarget.style.display = 'none'; - e.currentTarget.nextElementSibling?.classList.remove('hidden'); - }} - /> - ) : null} -
- -
-
-
- {user?.first_name || user?.username} -
-
- @{user?.username || `ID: ${user?.telegram_id}`} -
-
-
- {/* Language switcher in mobile menu when promo is active */} - {isPromoActive && } -
- - {/* Nav items */} - -
-
-
- )} - - {/* Main Content */} -
-
{children}
-
- - {/* Mobile Bottom Navigation - only core items, hidden when keyboard is open */} - -
- ); + return {children}; } diff --git a/src/components/motion/FadeIn.tsx b/src/components/motion/FadeIn.tsx new file mode 100644 index 0000000..e32ad13 --- /dev/null +++ b/src/components/motion/FadeIn.tsx @@ -0,0 +1,33 @@ +import { motion, type HTMLMotionProps } from 'framer-motion'; +import { forwardRef, type ReactNode } from 'react'; +import { fadeIn, fadeInTransition } from './transitions'; + +interface FadeInProps extends Omit, 'initial' | 'animate' | 'exit'> { + children: ReactNode; + delay?: number; + duration?: number; +} + +export const FadeIn = forwardRef( + ({ children, delay = 0, duration, ...props }, ref) => { + return ( + + {children} + + ); + }, +); + +FadeIn.displayName = 'FadeIn'; diff --git a/src/components/motion/SlideUp.tsx b/src/components/motion/SlideUp.tsx new file mode 100644 index 0000000..dd49d4f --- /dev/null +++ b/src/components/motion/SlideUp.tsx @@ -0,0 +1,33 @@ +import { motion, type HTMLMotionProps } from 'framer-motion'; +import { forwardRef, type ReactNode } from 'react'; +import { slideUp, slideUpTransition } from './transitions'; + +interface SlideUpProps extends Omit, 'initial' | 'animate' | 'exit'> { + children: ReactNode; + delay?: number; + duration?: number; +} + +export const SlideUp = forwardRef( + ({ children, delay = 0, duration, ...props }, ref) => { + return ( + + {children} + + ); + }, +); + +SlideUp.displayName = 'SlideUp'; diff --git a/src/components/motion/index.ts b/src/components/motion/index.ts new file mode 100644 index 0000000..6672f37 --- /dev/null +++ b/src/components/motion/index.ts @@ -0,0 +1,4 @@ +export { AnimatePresence } from 'framer-motion'; +export { FadeIn } from './FadeIn'; +export { SlideUp } from './SlideUp'; +export * from './transitions'; diff --git a/src/components/motion/transitions.ts b/src/components/motion/transitions.ts new file mode 100644 index 0000000..cb2aefc --- /dev/null +++ b/src/components/motion/transitions.ts @@ -0,0 +1,180 @@ +import type { Transition, Variants } from 'framer-motion'; + +// Spring transition for micro-interactions +export const springTransition: Transition = { + type: 'spring', + stiffness: 500, + 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; + +// Fade in animation +export const fadeIn: Variants = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, +}; + +export const fadeInTransition: Transition = { + duration: 0.2, + ease: easeOutExpo, +}; + +// Slide up animation (for page content, cards) +// Exit is instant to avoid visual glitches in Telegram Mini App +export const slideUp: Variants = { + initial: { opacity: 0, y: 8 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, transition: { duration: 0 } }, +}; + +export const slideUpTransition: Transition = { + duration: 0.2, + 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 }, + animate: { opacity: 1, scale: 1 }, + exit: { opacity: 0, scale: 0.95 }, +}; + +export const scaleTransition: Transition = { + duration: 0.2, + ease: easeOutExpo, +}; + +// Stagger container for lists +export const staggerContainer: Variants = { + initial: {}, + animate: { + transition: { + staggerChildren: 0.05, + delayChildren: 0.02, + }, + }, + exit: { + transition: { + staggerChildren: 0.02, + staggerDirection: -1, + }, + }, +}; + +// 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 = { + initial: { opacity: 0, y: 8 }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: 0, transition: { duration: 0 } }, +}; + +// Backdrop overlay +export const backdrop: Variants = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, +}; + +export const backdropTransition: Transition = { + duration: 0.15, +}; + +// Button press animation values +export const buttonTap = { + scale: 0.98, +}; + +export const buttonHover = { + scale: 1.02, +}; + +// Sheet/drawer slide up from bottom +export const sheetSlideUp: Variants = { + initial: { y: '100%' }, + animate: { y: 0 }, + exit: { y: '100%' }, +}; + +export const sheetTransition: Transition = { + type: 'spring', + damping: 30, + stiffness: 400, +}; + +// Tooltip animation +export const tooltip: Variants = { + initial: { opacity: 0, scale: 0.96, y: 2 }, + animate: { opacity: 1, scale: 1, y: 0 }, + exit: { opacity: 0, scale: 0.96, y: 2 }, +}; + +export const tooltipTransition: Transition = { + duration: 0.15, + ease: easeOutExpo, +}; + +// Dropdown menu animation +export const dropdown: Variants = { + initial: { opacity: 0, scale: 0.96, y: -4 }, + animate: { opacity: 1, scale: 1, y: 0 }, + exit: { opacity: 0, scale: 0.96, y: -4 }, +}; + +export const dropdownTransition: Transition = { + duration: 0.15, + ease: easeOutExpo, +}; + +// Command palette animation +export const commandPalette: Variants = { + initial: { opacity: 0, scale: 0.98 }, + animate: { opacity: 1, scale: 1 }, + exit: { opacity: 0, scale: 0.98 }, +}; + +export const commandPaletteTransition: Transition = { + duration: 0.15, + ease: easeOutExpo, +}; diff --git a/src/components/navigation/CommandPalette/CommandPalette.tsx b/src/components/navigation/CommandPalette/CommandPalette.tsx new file mode 100644 index 0000000..6aceaa3 --- /dev/null +++ b/src/components/navigation/CommandPalette/CommandPalette.tsx @@ -0,0 +1,218 @@ +import { useCallback, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { AnimatePresence, motion } from 'framer-motion'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; + +import { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, +} from '@/components/primitives/Command'; +import { usePlatform } from '@/platform'; +import { useAuthStore } from '@/store/auth'; +import { useTheme } from '@/hooks/useTheme'; +import { cn } from '@/lib/utils'; +import { + backdrop, + backdropTransition, + commandPalette, + commandPaletteTransition, +} from '@/components/motion/transitions'; + +// Icons +import { + HomeIcon, + SubscriptionIcon, + WalletIcon, + UsersIcon, + ChatIcon, + UserIcon, + GamepadIcon, + ClipboardIcon, + InfoIcon, + CogIcon, + WheelIcon, + PlusIcon, + DownloadIcon, + SunIcon, + MoonIcon, +} from '@/components/layout/AppShell/icons'; + +interface CommandPaletteProps { + open: boolean; + onOpenChange: (open: boolean) => void; + wheelEnabled?: boolean; + referralEnabled?: boolean; + hasContests?: boolean; + hasPolls?: boolean; +} + +export function CommandPalette({ + open, + onOpenChange, + wheelEnabled, + referralEnabled, + hasContests, + hasPolls, +}: CommandPaletteProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { haptic } = usePlatform(); + const { isAdmin } = useAuthStore(); + const { toggleTheme, isDark } = useTheme(); + const [search, setSearch] = useState(''); + + const runCommand = useCallback( + (command: () => void) => { + haptic.impact('light'); + onOpenChange(false); + command(); + }, + [haptic, onOpenChange], + ); + + // Navigation items + const navigationItems = [ + { label: t('nav.dashboard'), icon: HomeIcon, path: '/' }, + { label: t('nav.subscription'), icon: SubscriptionIcon, path: '/subscription' }, + { label: t('nav.balance'), icon: WalletIcon, path: '/balance' }, + ...(referralEnabled ? [{ label: t('nav.referral'), icon: UsersIcon, path: '/referral' }] : []), + { label: t('nav.support'), icon: ChatIcon, path: '/support' }, + ...(hasContests ? [{ label: t('nav.contests'), icon: GamepadIcon, path: '/contests' }] : []), + ...(hasPolls ? [{ label: t('nav.polls'), icon: ClipboardIcon, path: '/polls' }] : []), + ...(wheelEnabled ? [{ label: t('nav.wheel'), icon: WheelIcon, path: '/wheel' }] : []), + { label: t('nav.info'), icon: InfoIcon, path: '/info' }, + { label: t('nav.profile'), icon: UserIcon, path: '/profile' }, + ...(isAdmin ? [{ label: t('admin.nav.title'), icon: CogIcon, path: '/admin' }] : []), + ]; + + // Action items + const actionItems = [ + { + label: t('balance.top_up') || 'Top up balance', + icon: PlusIcon, + action: () => navigate('/balance'), + }, + { + label: t('subscription.get_config') || 'Get VPN config', + icon: DownloadIcon, + action: () => navigate('/subscription'), + }, + { + label: isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode', + icon: isDark ? SunIcon : MoonIcon, + action: toggleTheme, + }, + { + label: t('support.create_ticket') || 'Create support ticket', + icon: ChatIcon, + action: () => navigate('/support'), + }, + ]; + + return ( + + + + {open && ( + <> + {/* Backdrop */} + + + + + {/* Content */} + + + + + + {t('common.no_results') || 'No results found.'} + + {/* Navigation */} + + {navigationItems.map((item) => ( + runCommand(() => navigate(item.path))} + > + + {item.label} + + ))} + + + {/* Actions */} + + {actionItems.map((item) => ( + runCommand(item.action)} + > + + {item.label} + + ))} + + + + {/* Footer with keyboard hints */} +
+
+ + ↑↓ + + navigate +
+
+ + ↵ + + select +
+
+ + esc + + close +
+
+
+
+
+ + )} +
+
+
+ ); +} diff --git a/src/components/navigation/CommandPalette/index.ts b/src/components/navigation/CommandPalette/index.ts new file mode 100644 index 0000000..95c1305 --- /dev/null +++ b/src/components/navigation/CommandPalette/index.ts @@ -0,0 +1 @@ +export { CommandPalette } from './CommandPalette'; diff --git a/src/components/primitives/Button/Button.tsx b/src/components/primitives/Button/Button.tsx new file mode 100644 index 0000000..9414c0f --- /dev/null +++ b/src/components/primitives/Button/Button.tsx @@ -0,0 +1,102 @@ +import { Slot } from '@radix-ui/react-slot'; +import { motion, type HTMLMotionProps } from 'framer-motion'; +import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react'; +import { cn } from '@/lib/utils'; +import { usePlatform } from '@/platform'; +import { buttonTap, buttonHover, springTransition } from '../../motion/transitions'; +import { buttonVariants, type ButtonVariants } from './Button.variants'; + +export interface ButtonProps + extends Omit, 'disabled'>, ButtonVariants { + children: ReactNode; + asChild?: boolean; + disabled?: boolean; + loading?: boolean; + leftIcon?: ReactNode; + rightIcon?: ReactNode; + haptic?: boolean; +} + +export const Button = forwardRef( + ( + { + children, + className, + variant, + size, + fullWidth, + asChild = false, + disabled = false, + loading = false, + leftIcon, + rightIcon, + haptic = true, + onClick, + ...props + }, + ref, + ) => { + const { haptic: platformHaptic } = usePlatform(); + const isDisabled = disabled || loading; + + const handleClick = (e: React.MouseEvent) => { + if (haptic && !isDisabled) { + platformHaptic.impact('light'); + } + onClick?.(e); + }; + + const classes = cn(buttonVariants({ variant, size, fullWidth }), className); + + if (asChild) { + return ( + + {children} + + ); + } + + return ( + )} + > + {loading ? ( + + ) : ( + <> + {leftIcon && {leftIcon}} + {children} + {rightIcon && {rightIcon}} + + )} + + ); + }, +); + +Button.displayName = 'Button'; + +function LoadingSpinner() { + return ( + + + + + ); +} diff --git a/src/components/primitives/Button/Button.variants.ts b/src/components/primitives/Button/Button.variants.ts new file mode 100644 index 0000000..4893bc5 --- /dev/null +++ b/src/components/primitives/Button/Button.variants.ts @@ -0,0 +1,64 @@ +import { cva, type VariantProps } from 'class-variance-authority'; + +export const buttonVariants = cva( + // Base styles + [ + 'inline-flex items-center justify-center gap-2', + 'font-medium transition-all duration-200', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-950', + 'disabled:pointer-events-none disabled:opacity-50', + 'select-none', + ], + { + variants: { + variant: { + primary: [ + 'bg-accent-500 text-white', + 'hover:bg-accent-600', + 'active:bg-accent-700', + 'shadow-linear-sm hover:shadow-linear', + ], + secondary: [ + 'bg-dark-800/80 text-dark-100', + 'border border-dark-700/50', + 'hover:bg-dark-700/80 hover:border-dark-600/50', + 'active:bg-dark-800', + ], + ghost: ['text-dark-300', 'hover:text-dark-100 hover:bg-dark-800/50', 'active:bg-dark-800'], + destructive: [ + 'bg-error-500/10 text-error-400', + 'border border-error-500/20', + 'hover:bg-error-500/20 hover:border-error-500/30', + 'active:bg-error-500/30', + ], + outline: [ + 'border border-dark-700/50 text-dark-200', + 'hover:bg-dark-800/50 hover:border-dark-600/50 hover:text-dark-100', + 'active:bg-dark-800', + ], + link: [ + 'text-accent-400', + 'hover:text-accent-300 hover:underline', + 'active:text-accent-500', + ], + }, + size: { + sm: 'h-8 px-3 text-sm rounded-linear', + md: 'h-10 px-4 text-sm rounded-linear', + lg: 'h-12 px-6 text-base rounded-linear-lg', + icon: 'h-10 w-10 rounded-linear', + 'icon-sm': 'h-8 w-8 rounded-linear', + 'icon-lg': 'h-12 w-12 rounded-linear-lg', + }, + fullWidth: { + true: 'w-full', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, + }, +); + +export type ButtonVariants = VariantProps; diff --git a/src/components/primitives/Button/index.ts b/src/components/primitives/Button/index.ts new file mode 100644 index 0000000..c6f2572 --- /dev/null +++ b/src/components/primitives/Button/index.ts @@ -0,0 +1,2 @@ +export { Button, type ButtonProps } from './Button'; +export { buttonVariants, type ButtonVariants } from './Button.variants'; diff --git a/src/components/primitives/Command/Command.tsx b/src/components/primitives/Command/Command.tsx new file mode 100644 index 0000000..e611f6c --- /dev/null +++ b/src/components/primitives/Command/Command.tsx @@ -0,0 +1,172 @@ +import { Command as CommandPrimitive } from 'cmdk'; +import { forwardRef, type ComponentPropsWithoutRef, type HTMLAttributes } from 'react'; +import { cn } from '@/lib/utils'; + +// Search icon +const SearchIcon = () => ( + + + +); + +// Root Command +export type CommandProps = ComponentPropsWithoutRef; + +export const Command = forwardRef(({ className, ...props }, ref) => ( + +)); + +Command.displayName = 'Command'; + +// Input +export type CommandInputProps = ComponentPropsWithoutRef; + +export const CommandInput = forwardRef( + ({ className, ...props }, ref) => ( +
+ + +
+ ), +); + +CommandInput.displayName = 'CommandInput'; + +// List (scrollable area) +export type CommandListProps = ComponentPropsWithoutRef; + +export const CommandList = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +CommandList.displayName = 'CommandList'; + +// Empty state +export type CommandEmptyProps = ComponentPropsWithoutRef; + +export const CommandEmpty = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +CommandEmpty.displayName = 'CommandEmpty'; + +// Group +export type CommandGroupProps = ComponentPropsWithoutRef; + +export const CommandGroup = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +CommandGroup.displayName = 'CommandGroup'; + +// Separator +export type CommandSeparatorProps = ComponentPropsWithoutRef; + +export const CommandSeparator = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +CommandSeparator.displayName = 'CommandSeparator'; + +// Item +export type CommandItemProps = ComponentPropsWithoutRef; + +export const CommandItem = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +CommandItem.displayName = 'CommandItem'; + +// Shortcut display +export type CommandShortcutProps = HTMLAttributes; + +export const CommandShortcut = ({ className, ...props }: CommandShortcutProps) => ( + +); + +CommandShortcut.displayName = 'CommandShortcut'; + +// Loading state +export type CommandLoadingProps = ComponentPropsWithoutRef; + +export const CommandLoading = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +CommandLoading.displayName = 'CommandLoading'; diff --git a/src/components/primitives/Command/index.ts b/src/components/primitives/Command/index.ts new file mode 100644 index 0000000..6c6a8e0 --- /dev/null +++ b/src/components/primitives/Command/index.ts @@ -0,0 +1,20 @@ +export { + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandSeparator, + CommandItem, + CommandShortcut, + CommandLoading, + type CommandProps, + type CommandInputProps, + type CommandListProps, + type CommandEmptyProps, + type CommandGroupProps, + type CommandSeparatorProps, + type CommandItemProps, + type CommandShortcutProps, + type CommandLoadingProps, +} from './Command'; diff --git a/src/components/primitives/Dialog/Dialog.tsx b/src/components/primitives/Dialog/Dialog.tsx new file mode 100644 index 0000000..c1bfe23 --- /dev/null +++ b/src/components/primitives/Dialog/Dialog.tsx @@ -0,0 +1,190 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { motion, AnimatePresence } from 'framer-motion'; +import { + forwardRef, + type ComponentPropsWithoutRef, + createContext, + useContext, + useState, +} from 'react'; +import { cn } from '@/lib/utils'; +import { backdrop, backdropTransition, scale, scaleTransition } from '../../motion/transitions'; + +// Close icon +const CloseIcon = () => ( + + + +); + +// Context for AnimatePresence +const DialogContext = createContext<{ open: boolean }>({ open: false }); + +// Root +export type DialogProps = ComponentPropsWithoutRef; + +export const Dialog = ({ children, open, onOpenChange, ...props }: DialogProps) => { + const [internalOpen, setInternalOpen] = useState(false); + const isOpen = open !== undefined ? open : internalOpen; + const handleOpenChange = onOpenChange || setInternalOpen; + + return ( + + {children} + + ); +}; + +// Trigger +export const DialogTrigger = DialogPrimitive.Trigger; + +// Portal +export const DialogPortal = DialogPrimitive.Portal; + +// Close +export const DialogClose = DialogPrimitive.Close; + +// Overlay +export type DialogOverlayProps = ComponentPropsWithoutRef; + +export const DialogOverlay = forwardRef( + ({ className, ...props }, ref) => ( + + + + ), +); + +DialogOverlay.displayName = 'DialogOverlay'; + +// Content +export interface DialogContentProps extends ComponentPropsWithoutRef< + typeof DialogPrimitive.Content +> { + showCloseButton?: boolean; +} + +export const DialogContent = forwardRef( + ({ className, children, showCloseButton = true, ...props }, ref) => { + const { open } = useContext(DialogContext); + + return ( + + + {open && ( + <> + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + + )} + + + ); + }, +); + +DialogContent.displayName = 'DialogContent'; + +// Header +export type DialogHeaderProps = React.HTMLAttributes; + +export const DialogHeader = ({ className, ...props }: DialogHeaderProps) => ( +
+); + +DialogHeader.displayName = 'DialogHeader'; + +// Footer +export type DialogFooterProps = React.HTMLAttributes; + +export const DialogFooter = ({ className, ...props }: DialogFooterProps) => ( +
+); + +DialogFooter.displayName = 'DialogFooter'; + +// Title +export type DialogTitleProps = ComponentPropsWithoutRef; + +export const DialogTitle = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +DialogTitle.displayName = 'DialogTitle'; + +// Description +export type DialogDescriptionProps = ComponentPropsWithoutRef; + +export const DialogDescription = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +DialogDescription.displayName = 'DialogDescription'; diff --git a/src/components/primitives/Dialog/index.ts b/src/components/primitives/Dialog/index.ts new file mode 100644 index 0000000..28c3deb --- /dev/null +++ b/src/components/primitives/Dialog/index.ts @@ -0,0 +1,19 @@ +export { + Dialog, + DialogTrigger, + DialogPortal, + DialogClose, + DialogOverlay, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, + type DialogProps, + type DialogOverlayProps, + type DialogContentProps, + type DialogHeaderProps, + type DialogFooterProps, + type DialogTitleProps, + type DialogDescriptionProps, +} from './Dialog'; diff --git a/src/components/primitives/DropdownMenu/DropdownMenu.tsx b/src/components/primitives/DropdownMenu/DropdownMenu.tsx new file mode 100644 index 0000000..b923db7 --- /dev/null +++ b/src/components/primitives/DropdownMenu/DropdownMenu.tsx @@ -0,0 +1,294 @@ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { motion } from 'framer-motion'; +import { forwardRef, type ComponentPropsWithoutRef } from 'react'; +import { cn } from '@/lib/utils'; +import { usePlatform } from '@/platform'; +import { dropdown, dropdownTransition } from '../../motion/transitions'; + +// Icons +const CheckIcon = () => ( + + + +); + +const ChevronRightIcon = () => ( + + + +); + +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 +> { + inset?: boolean; +} + +export const DropdownMenuSubTrigger = forwardRef( + ({ className, inset, children, ...props }, ref) => ( + + {children} + + + ), +); + +DropdownMenuSubTrigger.displayName = 'DropdownMenuSubTrigger'; + +// SubContent +export type DropdownMenuSubContentProps = ComponentPropsWithoutRef< + typeof DropdownMenuPrimitive.SubContent +>; + +export const DropdownMenuSubContent = forwardRef( + ({ className, ...props }, ref) => ( + + + + ), +); + +DropdownMenuSubContent.displayName = 'DropdownMenuSubContent'; + +// Content +export type DropdownMenuContentProps = ComponentPropsWithoutRef< + typeof DropdownMenuPrimitive.Content +>; + +export const DropdownMenuContent = forwardRef( + ({ className, sideOffset = 4, ...props }, ref) => { + const { haptic } = usePlatform(); + + return ( + + haptic.impact('light')} + asChild + {...props} + > + + + + ); + }, +); + +DropdownMenuContent.displayName = 'DropdownMenuContent'; + +// Item +export interface DropdownMenuItemProps extends ComponentPropsWithoutRef< + typeof DropdownMenuPrimitive.Item +> { + inset?: boolean; + destructive?: boolean; +} + +export const DropdownMenuItem = forwardRef( + ({ className, inset, destructive, ...props }, ref) => { + const { haptic } = usePlatform(); + + return ( + haptic.impact('light')} + {...props} + /> + ); + }, +); + +DropdownMenuItem.displayName = 'DropdownMenuItem'; + +// CheckboxItem +export type DropdownMenuCheckboxItemProps = ComponentPropsWithoutRef< + typeof DropdownMenuPrimitive.CheckboxItem +>; + +export const DropdownMenuCheckboxItem = forwardRef( + ({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + + ), +); + +DropdownMenuCheckboxItem.displayName = 'DropdownMenuCheckboxItem'; + +// RadioItem +export type DropdownMenuRadioItemProps = ComponentPropsWithoutRef< + typeof DropdownMenuPrimitive.RadioItem +>; + +export const DropdownMenuRadioItem = forwardRef( + ({ className, children, ...props }, ref) => ( + + + + + + + {children} + + ), +); + +DropdownMenuRadioItem.displayName = 'DropdownMenuRadioItem'; + +// Label +export interface DropdownMenuLabelProps extends ComponentPropsWithoutRef< + typeof DropdownMenuPrimitive.Label +> { + inset?: boolean; +} + +export const DropdownMenuLabel = forwardRef( + ({ className, inset, ...props }, ref) => ( + + ), +); + +DropdownMenuLabel.displayName = 'DropdownMenuLabel'; + +// Separator +export type DropdownMenuSeparatorProps = ComponentPropsWithoutRef< + typeof DropdownMenuPrimitive.Separator +>; + +export const DropdownMenuSeparator = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +DropdownMenuSeparator.displayName = 'DropdownMenuSeparator'; + +// Shortcut +export type DropdownMenuShortcutProps = React.HTMLAttributes; + +export const DropdownMenuShortcut = ({ className, ...props }: DropdownMenuShortcutProps) => ( + +); + +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; diff --git a/src/components/primitives/DropdownMenu/index.ts b/src/components/primitives/DropdownMenu/index.ts new file mode 100644 index 0000000..9942af6 --- /dev/null +++ b/src/components/primitives/DropdownMenu/index.ts @@ -0,0 +1,26 @@ +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, + type DropdownMenuContentProps, + type DropdownMenuItemProps, + type DropdownMenuCheckboxItemProps, + type DropdownMenuRadioItemProps, + type DropdownMenuLabelProps, + type DropdownMenuSeparatorProps, + type DropdownMenuShortcutProps, + type DropdownMenuSubTriggerProps, + type DropdownMenuSubContentProps, +} from './DropdownMenu'; diff --git a/src/components/primitives/Popover/Popover.tsx b/src/components/primitives/Popover/Popover.tsx new file mode 100644 index 0000000..90b94e0 --- /dev/null +++ b/src/components/primitives/Popover/Popover.tsx @@ -0,0 +1,96 @@ +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import { motion } from 'framer-motion'; +import { forwardRef, type ComponentPropsWithoutRef } from 'react'; +import { cn } from '@/lib/utils'; +import { dropdown, dropdownTransition } from '../../motion/transitions'; + +// Close icon +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 +> { + showCloseButton?: boolean; +} + +export const PopoverContent = forwardRef( + ( + { className, children, align = 'center', sideOffset = 4, showCloseButton = false, ...props }, + ref, + ) => ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + + ), +); + +PopoverContent.displayName = 'PopoverContent'; + +// Arrow +export type PopoverArrowProps = ComponentPropsWithoutRef; + +export const PopoverArrow = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +PopoverArrow.displayName = 'PopoverArrow'; diff --git a/src/components/primitives/Popover/index.ts b/src/components/primitives/Popover/index.ts new file mode 100644 index 0000000..25c67f2 --- /dev/null +++ b/src/components/primitives/Popover/index.ts @@ -0,0 +1,10 @@ +export { + Popover, + PopoverTrigger, + PopoverAnchor, + PopoverClose, + PopoverContent, + PopoverArrow, + type PopoverContentProps, + type PopoverArrowProps, +} from './Popover'; diff --git a/src/components/primitives/Select/Select.tsx b/src/components/primitives/Select/Select.tsx new file mode 100644 index 0000000..f7cfdf7 --- /dev/null +++ b/src/components/primitives/Select/Select.tsx @@ -0,0 +1,183 @@ +import * as SelectPrimitive from '@radix-ui/react-select'; +import { motion } from 'framer-motion'; +import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react'; +import { cn } from '@/lib/utils'; +import { usePlatform } from '@/platform'; +import { dropdown, dropdownTransition } from '../../motion/transitions'; + +// Icons +const ChevronDownIcon = () => ( + + + +); + +const CheckIcon = () => ( + + + +); + +// Root +export const Select = SelectPrimitive.Root; + +// Trigger +export interface SelectTriggerProps extends ComponentPropsWithoutRef< + typeof SelectPrimitive.Trigger +> { + placeholder?: string; +} + +export const SelectTrigger = forwardRef( + ({ className, placeholder, ...props }, ref) => { + const { haptic } = usePlatform(); + + return ( + span]:line-clamp-1', + className, + )} + onClick={() => haptic.impact('light')} + {...props} + > + + + + + + ); + }, +); + +SelectTrigger.displayName = 'SelectTrigger'; + +// Content +export type SelectContentProps = ComponentPropsWithoutRef; + +export const SelectContent = forwardRef( + ({ className, children, position = 'popper', ...props }, ref) => ( + + + + + {children} + + + + + ), +); + +SelectContent.displayName = 'SelectContent'; + +// Item +export interface SelectItemProps extends ComponentPropsWithoutRef { + children: ReactNode; +} + +export const SelectItem = forwardRef( + ({ className, children, ...props }, ref) => ( + + {children} + + + + + + + ), +); + +SelectItem.displayName = 'SelectItem'; + +// Group +export const SelectGroup = SelectPrimitive.Group; + +// Label +export type SelectLabelProps = ComponentPropsWithoutRef; + +export const SelectLabel = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +SelectLabel.displayName = 'SelectLabel'; + +// Separator +export type SelectSeparatorProps = ComponentPropsWithoutRef; + +export const SelectSeparator = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +SelectSeparator.displayName = 'SelectSeparator'; diff --git a/src/components/primitives/Select/index.ts b/src/components/primitives/Select/index.ts new file mode 100644 index 0000000..e4b2890 --- /dev/null +++ b/src/components/primitives/Select/index.ts @@ -0,0 +1,14 @@ +export { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectGroup, + SelectLabel, + SelectSeparator, + type SelectTriggerProps, + type SelectContentProps, + type SelectItemProps, + type SelectLabelProps, + type SelectSeparatorProps, +} from './Select'; diff --git a/src/components/primitives/Sheet/Sheet.tsx b/src/components/primitives/Sheet/Sheet.tsx new file mode 100644 index 0000000..1c29024 --- /dev/null +++ b/src/components/primitives/Sheet/Sheet.tsx @@ -0,0 +1,283 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { motion, AnimatePresence, useDragControls, PanInfo } from 'framer-motion'; +import { + forwardRef, + type ComponentPropsWithoutRef, + createContext, + useContext, + useState, + useCallback, +} from 'react'; +import { cn } from '@/lib/utils'; +import { usePlatform } from '@/platform'; +import { useBackButton } from '@/platform'; +import { + backdrop, + backdropTransition, + sheetSlideUp, + sheetTransition, +} from '../../motion/transitions'; + +// Close icon +const CloseIcon = () => ( + + + +); + +// Context for AnimatePresence and control +interface SheetContextValue { + open: boolean; + onClose: () => void; +} + +const SheetContext = createContext({ + open: false, + onClose: () => {}, +}); + +// Root +export interface SheetProps extends ComponentPropsWithoutRef { + onClose?: () => void; +} + +export const Sheet = ({ children, open, onOpenChange, onClose, ...props }: SheetProps) => { + const [internalOpen, setInternalOpen] = useState(false); + const isOpen = open !== undefined ? open : internalOpen; + + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (onOpenChange) { + onOpenChange(newOpen); + } else { + setInternalOpen(newOpen); + } + + if (!newOpen && onClose) { + onClose(); + } + }, + [onOpenChange, onClose], + ); + + const handleClose = useCallback(() => { + handleOpenChange(false); + }, [handleOpenChange]); + + return ( + + + {children} + + + ); +}; + +// Trigger +export const SheetTrigger = DialogPrimitive.Trigger; + +// Portal +export const SheetPortal = DialogPrimitive.Portal; + +// Close +export const SheetClose = DialogPrimitive.Close; + +// Overlay +export type SheetOverlayProps = ComponentPropsWithoutRef; + +export const SheetOverlay = forwardRef( + ({ className, ...props }, ref) => ( + + + + ), +); + +SheetOverlay.displayName = 'SheetOverlay'; + +// Content +export interface SheetContentProps extends ComponentPropsWithoutRef< + typeof DialogPrimitive.Content +> { + showDragHandle?: boolean; + showCloseButton?: boolean; + enableDragToClose?: boolean; + closeThreshold?: number; +} + +export const SheetContent = forwardRef( + ( + { + className, + children, + showDragHandle = true, + showCloseButton = false, + enableDragToClose = true, + closeThreshold = 0.3, + ...props + }, + ref, + ) => { + 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; + const offset = info.offset.y; + const height = window.innerHeight; + + // Close if dragged down fast enough or past threshold + if (velocity > 500 || offset > height * closeThreshold) { + haptic.impact('light'); + onClose(); + } + }, + [closeThreshold, haptic, onClose], + ); + + return ( + + + {open && ( + <> + + + + {/* Drag handle */} + {showDragHandle && ( +
dragControls.start(e)} + > +
+
+ )} + + {/* Close button */} + {showCloseButton && ( + + + Close + + )} + + {/* Content */} +
{children}
+ + + + )} + + + ); + }, +); + +SheetContent.displayName = 'SheetContent'; + +// Header +export type SheetHeaderProps = React.HTMLAttributes; + +export const SheetHeader = ({ className, ...props }: SheetHeaderProps) => ( +
+); + +SheetHeader.displayName = 'SheetHeader'; + +// Footer +export type SheetFooterProps = React.HTMLAttributes; + +export const SheetFooter = ({ className, ...props }: SheetFooterProps) => ( +
+); + +SheetFooter.displayName = 'SheetFooter'; + +// Title +export type SheetTitleProps = ComponentPropsWithoutRef; + +export const SheetTitle = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +SheetTitle.displayName = 'SheetTitle'; + +// Description +export type SheetDescriptionProps = ComponentPropsWithoutRef; + +export const SheetDescription = forwardRef( + ({ className, ...props }, ref) => ( + + ), +); + +SheetDescription.displayName = 'SheetDescription'; diff --git a/src/components/primitives/Sheet/index.ts b/src/components/primitives/Sheet/index.ts new file mode 100644 index 0000000..f3c6bce --- /dev/null +++ b/src/components/primitives/Sheet/index.ts @@ -0,0 +1,19 @@ +export { + Sheet, + SheetTrigger, + SheetPortal, + SheetClose, + SheetOverlay, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, + type SheetProps, + type SheetOverlayProps, + type SheetContentProps, + type SheetHeaderProps, + type SheetFooterProps, + type SheetTitleProps, + type SheetDescriptionProps, +} from './Sheet'; diff --git a/src/components/primitives/Switch/Switch.tsx b/src/components/primitives/Switch/Switch.tsx new file mode 100644 index 0000000..6d914b6 --- /dev/null +++ b/src/components/primitives/Switch/Switch.tsx @@ -0,0 +1,73 @@ +import * as SwitchPrimitive from '@radix-ui/react-switch'; +import { motion } from 'framer-motion'; +import { forwardRef, type ComponentPropsWithoutRef } from 'react'; +import { cn } from '@/lib/utils'; +import { usePlatform } from '@/platform'; +import { springTransition } from '../../motion/transitions'; + +export interface SwitchProps extends Omit< + ComponentPropsWithoutRef, + 'onChange' +> { + label?: string; + description?: string; + onChange?: (checked: boolean) => void; + haptic?: boolean; +} + +export const Switch = forwardRef( + ({ className, label, description, onChange, onCheckedChange, haptic = true, ...props }, ref) => { + const { haptic: platformHaptic } = usePlatform(); + + const handleCheckedChange = (checked: boolean) => { + if (haptic) { + platformHaptic.impact('light'); + } + onCheckedChange?.(checked); + onChange?.(checked); + }; + + const switchElement = ( + + + + + + ); + + if (!label) { + return switchElement; + } + + return ( + + ); + }, +); + +Switch.displayName = 'Switch'; diff --git a/src/components/primitives/Switch/index.ts b/src/components/primitives/Switch/index.ts new file mode 100644 index 0000000..9e9f775 --- /dev/null +++ b/src/components/primitives/Switch/index.ts @@ -0,0 +1 @@ +export { Switch, type SwitchProps } from './Switch'; diff --git a/src/components/primitives/Tooltip/Tooltip.tsx b/src/components/primitives/Tooltip/Tooltip.tsx new file mode 100644 index 0000000..3d05860 --- /dev/null +++ b/src/components/primitives/Tooltip/Tooltip.tsx @@ -0,0 +1,75 @@ +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; +import { motion } from 'framer-motion'; +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; + +// Content +export type TooltipContentProps = ComponentPropsWithoutRef; + +export const TooltipContent = forwardRef( + ({ className, sideOffset = 4, children, ...props }, ref) => ( + + + + {children} + + + + ), +); + +TooltipContent.displayName = 'TooltipContent'; + +// Convenience wrapper component +export interface SimpleTooltipProps { + content: ReactNode; + children: ReactNode; + side?: 'top' | 'right' | 'bottom' | 'left'; + align?: 'start' | 'center' | 'end'; + delayDuration?: number; + className?: string; +} + +export const SimpleTooltip = ({ + content, + children, + side = 'top', + align = 'center', + delayDuration = 200, + className, +}: SimpleTooltipProps) => ( + + {children} + + {content} + + +); diff --git a/src/components/primitives/Tooltip/index.ts b/src/components/primitives/Tooltip/index.ts new file mode 100644 index 0000000..56daa18 --- /dev/null +++ b/src/components/primitives/Tooltip/index.ts @@ -0,0 +1,9 @@ +export { + TooltipProvider, + Tooltip, + TooltipTrigger, + TooltipContent, + SimpleTooltip, + type TooltipContentProps, + type SimpleTooltipProps, +} from './Tooltip'; diff --git a/src/components/primitives/index.ts b/src/components/primitives/index.ts new file mode 100644 index 0000000..5cdce5f --- /dev/null +++ b/src/components/primitives/index.ts @@ -0,0 +1,10 @@ +// Re-export all primitives from a single entry point +export * from './Button'; +export * from './Command'; +export * from './Dialog'; +export * from './DropdownMenu'; +export * from './Popover'; +export * from './Select'; +export * from './Sheet'; +export * from './Switch'; +export * from './Tooltip'; diff --git a/src/components/ui/BentoCard.tsx b/src/components/ui/BentoCard.tsx index 0cba0bb..85097cf 100644 --- a/src/components/ui/BentoCard.tsx +++ b/src/components/ui/BentoCard.tsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom'; -import { forwardRef } from 'react'; +import { forwardRef, useCallback } from 'react'; +import { useHaptic } from '@/platform'; export type BentoSize = 'sm' | 'md' | 'lg' | 'xl'; @@ -63,6 +64,7 @@ const glowClasses = ` export const BentoCard = forwardRef((props, ref) => { const { size = 'sm', children, className = '', hover = false, glow = false } = props; + const haptic = useHaptic(); const classes = [ baseClasses, @@ -74,10 +76,27 @@ export const BentoCard = forwardRef((props, ref) .filter(Boolean) .join(' '); + // Wrap click handlers to trigger haptic feedback when hover is enabled + const withHaptic = useCallback( + (onClick?: () => void) => { + if (!hover || !onClick) return onClick; + return () => { + haptic.impact('light'); + onClick(); + }; + }, + [hover, haptic], + ); + if (props.as === 'link') { const { to, state } = props as BentoCardLinkProps; return ( - + haptic.impact('light') : undefined} + > {children} ); @@ -89,7 +108,7 @@ export const BentoCard = forwardRef((props, ref) + )}

{t('admin.apps.title')}

diff --git a/src/pages/AdminBanSystem.tsx b/src/pages/AdminBanSystem.tsx index 18f231f..217788d 100644 --- a/src/pages/AdminBanSystem.tsx +++ b/src/pages/AdminBanSystem.tsx @@ -509,7 +509,7 @@ export default function AdminBanSystem() { href="https://t.me/fringg" target="_blank" rel="noopener noreferrer" - className="flex w-full items-center justify-center gap-3 rounded-xl bg-gradient-to-r from-[#0088cc] to-[#0099dd] px-4 py-3 font-medium text-white transition-all duration-200 hover:scale-[1.02] hover:from-[#0077bb] hover:to-[#0088cc] hover:shadow-lg hover:shadow-[#0088cc]/20" + className="flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-to-r from-[#0088cc] to-[#0099dd] px-4 py-2 text-sm font-medium text-white transition-all duration-200 hover:from-[#0077bb] hover:to-[#0088cc] hover:shadow-lg hover:shadow-[#0088cc]/20" > @@ -520,7 +520,7 @@ export default function AdminBanSystem() { {/* Back Button */}
+ {/* Header */} +
+ +
+
+ +
+
+

{t('admin.broadcasts.create')}

+

{t('admin.broadcasts.subtitle')}

+
+
+
+ + {/* Channel selection */} +
+ +
+ + +
+
+ + {/* Content */} +
+ {/* Section title */} +

+ {channel === 'telegram' + ? t('admin.broadcasts.telegramSection') + : t('admin.broadcasts.emailSection')} +

+ + {/* Filter selection */} +
+ +
+ + + {showFilters && ( +
+ {isFiltersLoading ? ( +
{t('common.loading')}
+ ) : ( + Object.entries(currentFilters).map(([group, filters]) => ( +
+
+ {FILTER_GROUP_LABEL_KEYS[group] ? t(FILTER_GROUP_LABEL_KEYS[group]) : group} +
+ {filters.map((filter) => ( + + ))} +
+ )) + )} +
+ )} +
+
+ + {/* Telegram-specific fields */} + {channel === 'telegram' && ( + <> + {/* Message text */} +
+ +