diff --git a/package-lock.json b/package-lock.json index 9e21138..ec18674 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,15 +13,30 @@ "@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", "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", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^13.5.0", "react-router-dom": "^7.13.0", + "tailwind-merge": "^3.4.0", "zustand": "^4.4.0" }, "devDependencies": { @@ -1025,6 +1040,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 +1237,1033 @@ "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/@rolldown/pluginutils": { "version": "1.0.0-beta.53", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", @@ -1617,8 +2697,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 +3110,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", @@ -2281,6 +3374,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 +3419,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", @@ -2459,6 +3589,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", @@ -3016,6 +4152,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 +4250,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 +4875,21 @@ "node": "*" } }, + "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", @@ -4323,6 +5510,53 @@ "node": ">=0.10.0" } }, + "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": { + "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": ">=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/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-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@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": "7.13.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", @@ -4361,6 +5595,28 @@ "react-dom": ">=18" } }, + "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": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -4725,6 +5981,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", @@ -4967,6 +6233,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", diff --git a/package.json b/package.json index 558b344..f1e67af 100644 --- a/package.json +++ b/package.json @@ -21,15 +21,30 @@ "@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", "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", "react": "^18.2.0", "react-dom": "^18.2.0", "react-i18next": "^13.5.0", "react-router-dom": "^7.13.0", + "tailwind-merge": "^3.4.0", "zustand": "^4.4.0" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index 23cc45b..57c0635 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'; @@ -47,6 +46,9 @@ const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods')); const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers')); const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')); 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(); @@ -405,6 +407,36 @@ function App() { } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> {/* Catch all */} } /> 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 c024027..780f7a2 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'; @@ -446,12 +441,14 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) { return (
- + {!isTelegramWebApp && ( + + )}

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

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

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

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

- + {!isTelegramWebApp && ( + + )}
+ ); + })} +
+ ); +} 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/layout/AppShell/AppHeader.tsx b/src/components/layout/AppShell/AppHeader.tsx new file mode 100644 index 0000000..3e6bac1 --- /dev/null +++ b/src/components/layout/AppShell/AppHeader.tsx @@ -0,0 +1,427 @@ +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 { promoApi } from '@/api/promo'; +import { cn } from '@/lib/utils'; + +import LanguageSwitcher from '@/components/LanguageSwitcher'; +import PromoDiscountBadge from '@/components/PromoDiscountBadge'; +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'; + +interface AppHeaderProps { + mobileMenuOpen: boolean; + setMobileMenuOpen: (open: boolean) => void; + onCommandPaletteOpen: () => void; + headerHeight: number; + isFullscreen: boolean; + safeAreaInset: { top: number; bottom: number }; + contentSafeAreaInset: { top: number; bottom: number }; + wheelEnabled?: boolean; + referralEnabled?: boolean; + hasContests?: boolean; + hasPolls?: boolean; +} + +export function AppHeader({ + mobileMenuOpen, + setMobileMenuOpen, + onCommandPaletteOpen, + headerHeight, + isFullscreen, + safeAreaInset, + contentSafeAreaInset, + wheelEnabled, + referralEnabled, + hasContests, + hasPolls, +}: AppHeaderProps) { + const { t } = useTranslation(); + const location = useLocation(); + const { user, logout, isAdmin, isAuthenticated } = 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; + + // Promo active check + const { data: activeDiscount } = useQuery({ + queryKey: ['active-discount'], + queryFn: promoApi.getActiveDiscount, + enabled: isAuthenticated, + staleTime: 30000, + }); + const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent; + + // 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)}> + +
+
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}`} +
+
+
+ {isPromoActive && } +
+ + {/* Nav items */} + +
+
+
+ )} + + ); +} diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx new file mode 100644 index 0000000..490a441 --- /dev/null +++ b/src/components/layout/AppShell/AppShell.tsx @@ -0,0 +1,517 @@ +import { useEffect, useState, useCallback, useRef } from 'react'; +import { useLocation, useNavigate, Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { AnimatePresence, motion } from 'framer-motion'; +import { useTranslation } from 'react-i18next'; + +import { useAuthStore } from '@/store/auth'; +import { useBackButton, useHaptic } from '@/platform'; +import { useTelegramWebApp } from '@/hooks/useTelegramWebApp'; +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, isTelegramMobile } from '@/hooks/useTelegramWebApp'; +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 { slideUp, slideUpTransition } from '@/components/motion/transitions'; + +// 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 } = + useTelegramWebApp(); + const haptic = useHaptic(); + + // Only apply fullscreen UI adjustments on mobile Telegram (iOS/Android) + const isMobileFullscreen = isFullscreen && isTelegramMobile(); + + 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 && isTelegramMobile()) { + requestFullscreen(); + } + }, [fullscreenSetting, isTelegramWebApp, isFullscreen, requestFullscreen]); + + // 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']; + 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) + const headerHeight = isMobileFullscreen + ? 64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45 + : 64; + + return ( +
+ {/* 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} + wheelEnabled={wheelConfig?.is_enabled} + referralEnabled={referralTerms?.is_enabled} + hasContests={(contestsCount?.count ?? 0) > 0} + hasPolls={(pollsCount?.count ?? 0) > 0} + /> + + {/* Desktop spacer */} +
+ + {/* Mobile spacer */} +
+ + {/* Main content */} +
+ {/* Disable animations for admin pages to prevent scroll jumps in Telegram Mini App */} + {location.pathname.startsWith('/admin') ? ( + children + ) : ( + + + {children} + + + )} +
+ + {/* Mobile Bottom Navigation */} + +
+ ); +} 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..4b6198e --- /dev/null +++ b/src/components/layout/AppShell/MobileBottomNav.tsx @@ -0,0 +1,78 @@ +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 } from './icons'; + +interface MobileBottomNavProps { + isKeyboardOpen: boolean; + referralEnabled?: boolean; +} + +export function MobileBottomNav({ isKeyboardOpen, referralEnabled }: MobileBottomNavProps) { + const { t } = useTranslation(); + const location = useLocation(); + const { haptic } = usePlatform(); + + const isActive = (path: string) => location.pathname === path; + + // Core navigation items for bottom bar + 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 }] : []), + { path: '/support', label: t('nav.support'), icon: ChatIcon }, + ]; + + const handleNavClick = () => { + haptic.impact('light'); + }; + + return ( + + ); +} diff --git a/src/components/layout/AppShell/icons.tsx b/src/components/layout/AppShell/icons.tsx new file mode 100644 index 0000000..acfd41b --- /dev/null +++ b/src/components/layout/AppShell/icons.tsx @@ -0,0 +1,330 @@ +import { cn } from '@/lib/utils'; + +interface IconProps { + className?: string; +} + +export const HomeIcon = ({ className }: IconProps) => ( + + + +); + +export const SubscriptionIcon = ({ className }: IconProps) => ( + + + +); + +export const WalletIcon = ({ className }: IconProps) => ( + + + +); + +export const UsersIcon = ({ className }: IconProps) => ( + + + +); + +export const ChatIcon = ({ className }: IconProps) => ( + + + +); + +export const UserIcon = ({ className }: IconProps) => ( + + + +); + +export const LogoutIcon = ({ className }: IconProps) => ( + + + +); + +export const SunIcon = ({ className }: IconProps) => ( + + + +); + +export const MoonIcon = ({ className }: IconProps) => ( + + + +); + +export const MenuIcon = ({ className }: IconProps) => ( + + + +); + +export const CloseIcon = ({ className }: IconProps) => ( + + + +); + +export const GamepadIcon = ({ className }: IconProps) => ( + + + +); + +export const ClipboardIcon = ({ className }: IconProps) => ( + + + +); + +export const InfoIcon = ({ className }: IconProps) => ( + + + +); + +export const CogIcon = ({ className }: IconProps) => ( + + + + +); + +export const WheelIcon = ({ className }: IconProps) => ( + + + +); + +export const SearchIcon = ({ className }: IconProps) => ( + + + +); + +export const PlusIcon = ({ className }: IconProps) => ( + + + +); + +export const ArrowRightIcon = ({ className }: IconProps) => ( + + + +); + +export const DownloadIcon = ({ className }: IconProps) => ( + + + +); + +export const PaletteIcon = ({ className }: IconProps) => ( + + + +); diff --git a/src/components/layout/AppShell/index.ts b/src/components/layout/AppShell/index.ts new file mode 100644 index 0000000..233b39d --- /dev/null +++ b/src/components/layout/AppShell/index.ts @@ -0,0 +1,4 @@ +export { AppShell } from './AppShell'; +export { DesktopSidebar } from './DesktopSidebar'; +export { MobileBottomNav } from './MobileBottomNav'; +export { AppHeader } from './AppHeader'; 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..8ea3ec2 --- /dev/null +++ b/src/components/primitives/Button/Button.tsx @@ -0,0 +1,104 @@ +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 ( + + + + + ); +} + +export { buttonVariants, type ButtonVariants }; 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..ce23aa1 --- /dev/null +++ b/src/components/primitives/Button/index.ts @@ -0,0 +1 @@ +export { Button, type ButtonProps, buttonVariants, type ButtonVariants } from './Button'; 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..a88b994 --- /dev/null +++ b/src/components/primitives/Sheet/Sheet.tsx @@ -0,0 +1,284 @@ +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) +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +
+
+

+ {t('admin.broadcasts.detail')} #{broadcast.id} +

+ + +
+

+ {new Date(broadcast.created_at).toLocaleString()} +

+
+
+ +
+ + {/* Progress */} + {isRunning && ( +
+
+ {t('admin.broadcasts.progress')} + + {broadcast.progress_percent.toFixed(1)}% + +
+
+
+
+
+ )} + + {/* Stats */} +
+
+

{broadcast.total_count}

+

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

+
+
+

{broadcast.sent_count}

+

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

+
+
+

{broadcast.failed_count}

+

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

+
+
+ + {/* Target */} +
+

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

+

{broadcast.target_type}

+
+ + {/* Telegram Message */} + {broadcast.message_text && ( +
+

+ + {t('admin.broadcasts.message')} +

+
+ {broadcast.message_text} +
+
+ )} + + {/* Email Subject */} + {broadcast.email_subject && ( +
+

+ + {t('admin.broadcasts.emailSubject')} +

+
+ {broadcast.email_subject} +
+
+ )} + + {/* Email Content */} + {broadcast.email_html_content && ( +
+

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

+
+ {broadcast.email_html_content} +
+
+ )} + + {/* Media */} + {broadcast.has_media && ( +
+

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

+
+ {broadcast.media_type === 'photo' && } + {broadcast.media_type === 'video' && } + {broadcast.media_type === 'document' && } + {broadcast.media_type} +
+
+ )} + + {/* Admin info */} +
+ + {t('admin.broadcasts.createdBy')}:{' '} + + {broadcast.admin_name || t('admin.broadcasts.unknownAdmin')} + + + {new Date(broadcast.created_at).toLocaleString()} +
+ + {/* Stop button */} + {isRunning && broadcast.status !== 'cancelling' && ( + + )} +
+ ); +} diff --git a/src/pages/AdminBroadcasts.tsx b/src/pages/AdminBroadcasts.tsx index a8edc8f..1c5440e 100644 --- a/src/pages/AdminBroadcasts.tsx +++ b/src/pages/AdminBroadcasts.tsx @@ -1,28 +1,16 @@ import { useState, useRef, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; import { adminBroadcastsApi, - Broadcast, BroadcastFilter, TariffFilter, - BroadcastChannel, - CombinedBroadcastCreateRequest, + BroadcastCreateRequest, } from '../api/adminBroadcasts'; +import { AdminBackButton } from '../components/admin'; // Icons -const BackIcon = () => ( - - - -); const BroadcastIcon = () => ( @@ -56,16 +44,6 @@ const RefreshIcon = () => ( ); -const StopIcon = () => ( - - - -); - const PhotoIcon = () => ( ( ); -const TelegramIcon = () => ( - - - -); - -const EmailIcon = () => ( - - - -); - // Status badge component const statusConfig: Record = { queued: { - bg: 'bg-yellow-500/20', - text: 'text-yellow-400', + bg: 'bg-warning-500/20', + text: 'text-warning-400', labelKey: 'admin.broadcasts.status.queued', }, in_progress: { - bg: 'bg-blue-500/20', - text: 'text-blue-400', + bg: 'bg-accent-500/20', + text: 'text-accent-400', labelKey: 'admin.broadcasts.status.inProgress', }, completed: { - bg: 'bg-green-500/20', - text: 'text-green-400', + bg: 'bg-success-500/20', + text: 'text-success-400', labelKey: 'admin.broadcasts.status.completed', }, partial: { - bg: 'bg-orange-500/20', - text: 'text-orange-400', + bg: 'bg-warning-500/20', + text: 'text-warning-400', labelKey: 'admin.broadcasts.status.partial', }, - failed: { bg: 'bg-red-500/20', text: 'text-red-400', labelKey: 'admin.broadcasts.status.failed' }, + failed: { + bg: 'bg-error-500/20', + text: 'text-error-400', + labelKey: 'admin.broadcasts.status.failed', + }, cancelled: { - bg: 'bg-gray-500/20', - text: 'text-gray-400', + bg: 'bg-dark-500/20', + text: 'text-dark-400', labelKey: 'admin.broadcasts.status.cancelled', }, cancelling: { - bg: 'bg-yellow-500/20', - text: 'text-yellow-400', + bg: 'bg-warning-500/20', + text: 'text-warning-400', labelKey: 'admin.broadcasts.status.cancelling', }, }; @@ -173,38 +139,6 @@ function StatusBadge({ status }: { status: string }) { ); } -// Channel badge component -function ChannelBadge({ channel }: { channel?: BroadcastChannel }) { - const { t } = useTranslation(); - - if (!channel || channel === 'telegram') { - return ( - - - Telegram - - ); - } - - if (channel === 'email') { - return ( - - - Email - - ); - } - - return ( - - - + - - {t('admin.broadcasts.channel.both')} - - ); -} - // Filter labels (labelKey pattern: store i18n keys, resolve at render time with t()) const FILTER_GROUP_LABEL_KEYS: Record = { basic: 'admin.broadcasts.filterGroups.basic', @@ -214,7 +148,6 @@ const FILTER_GROUP_LABEL_KEYS: Record = { activity: 'admin.broadcasts.filterGroups.activity', source: 'admin.broadcasts.filterGroups.source', tariff: 'admin.broadcasts.filterGroups.tariff', - email: 'admin.broadcasts.filterGroups.email', }; // Create broadcast modal @@ -228,14 +161,7 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { const queryClient = useQueryClient(); const fileInputRef = useRef(null); - // Channel selection - const [channel, setChannel] = useState('telegram'); - - // Common fields const [target, setTarget] = useState(''); - const [emailTarget, setEmailTarget] = useState(''); - - // Telegram fields const [messageText, setMessageText] = useState(''); const [selectedButtons, setSelectedButtons] = useState(['home']); const [mediaFile, setMediaFile] = useState(null); @@ -243,48 +169,28 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { const [mediaPreview, setMediaPreview] = useState(null); const [uploadedFileId, setUploadedFileId] = useState(null); const [isUploading, setIsUploading] = useState(false); - - // Email fields - const [emailSubject, setEmailSubject] = useState(''); - const [emailHtmlContent, setEmailHtmlContent] = useState(''); - - // UI state const [showFilters, setShowFilters] = useState(false); - const [showEmailFilters, setShowEmailFilters] = useState(false); - // Fetch Telegram filters + // Fetch filters const { data: filtersData, isLoading: filtersLoading } = useQuery({ queryKey: ['admin', 'broadcasts', 'filters'], queryFn: adminBroadcastsApi.getFilters, - enabled: channel === 'telegram' || channel === 'both', - }); - - // Fetch Email filters - const { data: emailFiltersData, isLoading: emailFiltersLoading } = useQuery({ - queryKey: ['admin', 'broadcasts', 'email-filters'], - queryFn: adminBroadcastsApi.getEmailFilters, - enabled: channel === 'email' || channel === 'both', }); // Fetch buttons const { data: buttonsData } = useQuery({ queryKey: ['admin', 'broadcasts', 'buttons'], queryFn: adminBroadcastsApi.getButtons, - enabled: channel === 'telegram' || channel === 'both', }); - // Preview mutations + // Preview mutation const previewMutation = useMutation({ mutationFn: adminBroadcastsApi.preview, }); - const previewEmailMutation = useMutation({ - mutationFn: adminBroadcastsApi.previewEmail, - }); - // Create mutation const createMutation = useMutation({ - mutationFn: adminBroadcastsApi.createCombined, + mutationFn: adminBroadcastsApi.create, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] }); onSuccess(); @@ -319,20 +225,6 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { return groups; }, [filtersData]); - // Group email filters - const groupedEmailFilters = useMemo(() => { - if (!emailFiltersData) return {}; - const groups: Record = {}; - - emailFiltersData.filters.forEach((f) => { - const group = f.group || 'email'; - if (!groups[group]) groups[group] = []; - groups[group].push(f); - }); - - return groups; - }, [emailFiltersData]); - // Selected filter info const selectedFilter = useMemo(() => { if (!target || !filtersData) return null; @@ -344,11 +236,6 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { return all.find((f) => f.key === target); }, [target, filtersData]); - const selectedEmailFilter = useMemo(() => { - if (!emailTarget || !emailFiltersData) return null; - return emailFiltersData.filters.find((f) => f.key === emailTarget); - }, [emailTarget, emailFiltersData]); - // Handle file selection const handleFileSelect = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -399,77 +286,30 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { ); }; - // Validation - const canSubmit = useMemo(() => { - if (createMutation.isPending || isUploading) return false; - - if (channel === 'telegram') { - return !!target && !!messageText.trim(); - } - - if (channel === 'email') { - return !!emailTarget && !!emailSubject.trim() && !!emailHtmlContent.trim(); - } - - // Both - return ( - !!target && - !!messageText.trim() && - !!emailTarget && - !!emailSubject.trim() && - !!emailHtmlContent.trim() - ); - }, [ - channel, - target, - messageText, - emailTarget, - emailSubject, - emailHtmlContent, - createMutation.isPending, - isUploading, - ]); - // Submit const handleSubmit = () => { - if (!canSubmit) return; + if (!target || !messageText.trim()) return; - const data: CombinedBroadcastCreateRequest = { - channel, - target: channel === 'email' ? emailTarget : target, + const data: BroadcastCreateRequest = { + target, + message_text: messageText, + selected_buttons: selectedButtons, }; - // Telegram data - if (channel === 'telegram' || channel === 'both') { - data.message_text = messageText; - data.selected_buttons = selectedButtons; - - if (uploadedFileId) { - data.media = { - type: mediaType, - file_id: uploadedFileId, - }; - } - } - - // Email data - if (channel === 'email' || channel === 'both') { - data.email_subject = emailSubject; - data.email_html_content = emailHtmlContent; - if (channel === 'both') { - data.target = target; // Use Telegram target as primary - } + if (uploadedFileId) { + data.media = { + type: mediaType, + file_id: uploadedFileId, + }; } createMutation.mutate(data); }; - const telegramRecipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null; - const emailRecipientsCount = - previewEmailMutation.data?.count ?? selectedEmailFilter?.count ?? null; + const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null; return ( -
+
{/* Header */}
@@ -486,346 +326,177 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { {/* Content */}
- {/* Channel selection */} + {/* Filter selection */}
-
+
- - + + {showFilters && ( +
+ {filtersLoading ? ( +
Loading...
+ ) : ( + Object.entries(groupedFilters).map(([group, filters]) => ( +
+
+ {FILTER_GROUP_LABEL_KEYS[group] + ? t(FILTER_GROUP_LABEL_KEYS[group]) + : group} +
+ {filters.map((filter) => ( + + ))} +
+ )) + )} +
+ )}
- {/* Telegram Section */} - {(channel === 'telegram' || channel === 'both') && ( -
-
- - {t('admin.broadcasts.telegramSection')} -
+ {/* Message text */} +
+ +