mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
394
package-lock.json
generated
394
package-lock.json
generated
@@ -36,11 +36,13 @@
|
||||
"i18next": "^25.8.4",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jsencrypt": "^3.5.4",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-router": "^7.13.0",
|
||||
"react-twemoji": "^0.7.2",
|
||||
"recharts": "^3.7.0",
|
||||
"simplex-noise": "^4.0.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.11"
|
||||
@@ -2244,6 +2246,42 @@
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
||||
@@ -2601,6 +2639,18 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.20",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||
@@ -2826,6 +2876,69 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
@@ -2897,6 +3010,12 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",
|
||||
@@ -3756,6 +3875,127 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -3774,6 +4014,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3919,6 +4165,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
@@ -4187,7 +4443,6 @@
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
@@ -4692,6 +4947,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -4719,6 +4984,15 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -5673,6 +5947,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode.react": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
|
||||
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -5742,6 +6025,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
@@ -5883,6 +6196,57 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -6307,6 +6671,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -6574,6 +6944,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
|
||||
@@ -44,11 +44,13 @@
|
||||
"i18next": "^25.8.4",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jsencrypt": "^3.5.4",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-router": "^7.13.0",
|
||||
"react-twemoji": "^0.7.2",
|
||||
"recharts": "^3.7.0",
|
||||
"simplex-noise": "^4.0.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.11"
|
||||
|
||||
31
src/App.tsx
31
src/App.tsx
@@ -26,6 +26,7 @@ import Dashboard from './pages/Dashboard';
|
||||
|
||||
// User pages - lazy load
|
||||
const Subscription = lazy(() => import('./pages/Subscription'));
|
||||
const SubscriptionPurchase = lazy(() => import('./pages/SubscriptionPurchase'));
|
||||
const Balance = lazy(() => import('./pages/Balance'));
|
||||
const Referral = lazy(() => import('./pages/Referral'));
|
||||
const Support = lazy(() => import('./pages/Support'));
|
||||
@@ -35,6 +36,7 @@ const Polls = lazy(() => import('./pages/Polls'));
|
||||
const Info = lazy(() => import('./pages/Info'));
|
||||
const Wheel = lazy(() => import('./pages/Wheel'));
|
||||
const Connection = lazy(() => import('./pages/Connection'));
|
||||
const ConnectionQR = lazy(() => import('./pages/ConnectionQR'));
|
||||
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
|
||||
const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
|
||||
|
||||
@@ -85,6 +87,7 @@ const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'));
|
||||
const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail'));
|
||||
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
|
||||
const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage'));
|
||||
const AdminSalesStats = lazy(() => import('./pages/AdminSalesStats'));
|
||||
const AdminUpdates = lazy(() => import('./pages/AdminUpdates'));
|
||||
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
||||
const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail'));
|
||||
@@ -202,6 +205,16 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/subscription/purchase"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<SubscriptionPurchase />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/balance"
|
||||
element={
|
||||
@@ -322,6 +335,14 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/connection/qr"
|
||||
element={
|
||||
<LazyPage>
|
||||
<ConnectionQR />
|
||||
</LazyPage>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/connection"
|
||||
element={
|
||||
@@ -724,6 +745,16 @@ function App() {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/sales-stats"
|
||||
element={
|
||||
<PermissionRoute permission="stats:read">
|
||||
<LazyPage>
|
||||
<AdminSalesStats />
|
||||
</LazyPage>
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/payment-methods"
|
||||
element={
|
||||
|
||||
@@ -243,8 +243,9 @@ export interface DashboardStats {
|
||||
|
||||
export interface TopReferrerItem {
|
||||
user_id: number;
|
||||
telegram_id: number;
|
||||
telegram_id: number | null;
|
||||
username?: string;
|
||||
email?: string | null;
|
||||
display_name: string;
|
||||
invited_count: number;
|
||||
invited_today: number;
|
||||
@@ -288,15 +289,16 @@ export interface TopCampaignsResponse {
|
||||
export interface RecentPaymentItem {
|
||||
id: number;
|
||||
user_id: number;
|
||||
telegram_id: number;
|
||||
username?: string;
|
||||
telegram_id: number | null;
|
||||
email?: string | null;
|
||||
username?: string | null;
|
||||
display_name: string;
|
||||
amount_kopeks: number;
|
||||
amount_rubles: number;
|
||||
type: string;
|
||||
type_display: string;
|
||||
payment_method?: string;
|
||||
description?: string;
|
||||
payment_method?: string | null;
|
||||
description?: string | null;
|
||||
created_at: string;
|
||||
is_completed: boolean;
|
||||
}
|
||||
|
||||
199
src/api/adminSalesStats.ts
Normal file
199
src/api/adminSalesStats.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import apiClient from './client';
|
||||
|
||||
// ============ Period Params ============
|
||||
|
||||
export interface SalesStatsParams {
|
||||
days?: number;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
// ============ Summary ============
|
||||
|
||||
export interface SalesSummary {
|
||||
total_revenue_kopeks: number;
|
||||
active_subscriptions: number;
|
||||
active_trials: number;
|
||||
new_trials: number;
|
||||
trial_to_paid_conversion: number;
|
||||
renewals_count: number;
|
||||
addon_revenue_kopeks: number;
|
||||
}
|
||||
|
||||
// ============ Trials ============
|
||||
|
||||
export interface ProviderBreakdownItem {
|
||||
provider: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DailyTrialItem {
|
||||
date: string;
|
||||
registrations: number;
|
||||
trials: number;
|
||||
}
|
||||
|
||||
export interface TrialsStats {
|
||||
total_trials: number;
|
||||
total_registrations: number;
|
||||
conversion_rate: number;
|
||||
avg_trial_duration_days: number;
|
||||
by_provider: ProviderBreakdownItem[];
|
||||
daily: DailyTrialItem[];
|
||||
}
|
||||
|
||||
// ============ Sales ============
|
||||
|
||||
export interface SalesByTariffItem {
|
||||
tariff_id: number;
|
||||
tariff_name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SalesByPeriodItem {
|
||||
period_days: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DailySalesItem {
|
||||
date: string;
|
||||
count: number;
|
||||
revenue_kopeks: number;
|
||||
}
|
||||
|
||||
export interface DailyTariffSalesItem {
|
||||
date: string;
|
||||
tariff_name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SalesStats {
|
||||
total_sales: number;
|
||||
total_revenue_kopeks: number;
|
||||
avg_order_kopeks: number;
|
||||
top_tariff_name: string;
|
||||
by_tariff: SalesByTariffItem[];
|
||||
by_period: SalesByPeriodItem[];
|
||||
daily: DailySalesItem[];
|
||||
daily_by_tariff: DailyTariffSalesItem[];
|
||||
}
|
||||
|
||||
// ============ Renewals ============
|
||||
|
||||
export interface RenewalPeriodStats {
|
||||
count: number;
|
||||
revenue_kopeks: number;
|
||||
}
|
||||
|
||||
export interface RenewalChange {
|
||||
absolute: number;
|
||||
percent: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
}
|
||||
|
||||
export interface DailyRenewalItem {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface RenewalsStats {
|
||||
total_renewals: number;
|
||||
total_revenue_kopeks: number;
|
||||
renewal_rate: number;
|
||||
current_period: RenewalPeriodStats;
|
||||
previous_period: RenewalPeriodStats;
|
||||
change: RenewalChange;
|
||||
daily: DailyRenewalItem[];
|
||||
}
|
||||
|
||||
// ============ Add-ons ============
|
||||
|
||||
export interface AddonByPackageItem {
|
||||
traffic_gb: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DailyAddonItem {
|
||||
date: string;
|
||||
count: number;
|
||||
total_gb: number;
|
||||
}
|
||||
|
||||
export interface DailyDeviceItem {
|
||||
date: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AddonsStats {
|
||||
total_purchases: number;
|
||||
total_gb_purchased: number;
|
||||
addon_revenue_kopeks: number;
|
||||
device_purchases: number;
|
||||
device_revenue_kopeks: number;
|
||||
by_package: AddonByPackageItem[];
|
||||
daily: DailyAddonItem[];
|
||||
daily_devices: DailyDeviceItem[];
|
||||
}
|
||||
|
||||
// ============ Deposits ============
|
||||
|
||||
export interface DepositByMethodItem {
|
||||
method: string;
|
||||
count: number;
|
||||
amount_kopeks: number;
|
||||
}
|
||||
|
||||
export interface DailyDepositItem {
|
||||
date: string;
|
||||
count: number;
|
||||
amount_kopeks: number;
|
||||
}
|
||||
|
||||
export interface DailyDepositByMethodItem {
|
||||
date: string;
|
||||
method: string;
|
||||
amount_kopeks: number;
|
||||
}
|
||||
|
||||
export interface DepositsStats {
|
||||
total_deposits: number;
|
||||
total_amount_kopeks: number;
|
||||
avg_deposit_kopeks: number;
|
||||
by_method: DepositByMethodItem[];
|
||||
daily: DailyDepositItem[];
|
||||
daily_by_method: DailyDepositByMethodItem[];
|
||||
}
|
||||
|
||||
// ============ API ============
|
||||
|
||||
export const salesStatsApi = {
|
||||
getSummary: async (params: SalesStatsParams = {}): Promise<SalesSummary> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/sales/summary', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTrials: async (params: SalesStatsParams = {}): Promise<TrialsStats> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/sales/trials', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSales: async (params: SalesStatsParams = {}): Promise<SalesStats> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/sales/subscriptions', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getRenewals: async (params: SalesStatsParams = {}): Promise<RenewalsStats> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/sales/renewals', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getAddons: async (params: SalesStatsParams = {}): Promise<AddonsStats> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/sales/addons', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getDeposits: async (params: SalesStatsParams = {}): Promise<DepositsStats> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/sales/deposits', { params });
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -174,6 +174,7 @@ export const authApi = {
|
||||
provider: string,
|
||||
code: string,
|
||||
state: string,
|
||||
deviceId?: string | null,
|
||||
campaignSlug?: string | null,
|
||||
referralCode?: string | null,
|
||||
): Promise<AuthResponse> => {
|
||||
@@ -182,6 +183,7 @@ export const authApi = {
|
||||
{
|
||||
code,
|
||||
state,
|
||||
device_id: deviceId || undefined,
|
||||
campaign_slug: campaignSlug || undefined,
|
||||
referral_code: referralCode || undefined,
|
||||
},
|
||||
|
||||
@@ -149,6 +149,49 @@ export interface CampaignsOverview {
|
||||
total_tariff_issued: number;
|
||||
}
|
||||
|
||||
export interface AdminDailyStatItem {
|
||||
date: string;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface AdminPeriodStats {
|
||||
days: number;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface AdminPeriodChange {
|
||||
absolute: number;
|
||||
percent: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
}
|
||||
|
||||
export interface AdminPeriodComparison {
|
||||
current: AdminPeriodStats;
|
||||
previous: AdminPeriodStats;
|
||||
referrals_change: AdminPeriodChange;
|
||||
earnings_change: AdminPeriodChange;
|
||||
}
|
||||
|
||||
export interface AdminTopRegistrationItem {
|
||||
id: number;
|
||||
full_name: string;
|
||||
created_at: string;
|
||||
has_paid: boolean;
|
||||
is_active: boolean;
|
||||
total_earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface AdminCampaignChartData {
|
||||
campaign_id: number;
|
||||
total_deposits_kopeks: number;
|
||||
total_spending_kopeks: number;
|
||||
daily_stats: AdminDailyStatItem[];
|
||||
period_comparison: AdminPeriodComparison;
|
||||
top_registrations: AdminTopRegistrationItem[];
|
||||
}
|
||||
|
||||
export interface ServerSquadInfo {
|
||||
id: number;
|
||||
squad_uuid: string;
|
||||
@@ -258,4 +301,10 @@ export const campaignsApi = {
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns/available-partners');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get campaign chart data for analytics
|
||||
getChartData: async (campaignId: number): Promise<AdminCampaignChartData> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/chart-data`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface PartnerApplicationInfo {
|
||||
telegram_channel: string | null;
|
||||
description: string | null;
|
||||
expected_monthly_referrals: number | null;
|
||||
desired_commission_percent: number | null;
|
||||
admin_comment: string | null;
|
||||
approved_commission_percent: number | null;
|
||||
created_at: string;
|
||||
@@ -26,6 +27,10 @@ export interface PartnerCampaignInfo {
|
||||
subscription_traffic_gb: number | null;
|
||||
deep_link: string | null;
|
||||
web_link: string | null;
|
||||
// Per-campaign statistics
|
||||
registrations_count: number;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface PartnerStatusResponse {
|
||||
@@ -41,6 +46,58 @@ export interface PartnerApplicationRequest {
|
||||
telegram_channel?: string;
|
||||
description?: string;
|
||||
expected_monthly_referrals?: number;
|
||||
desired_commission_percent?: number;
|
||||
}
|
||||
|
||||
// ==================== Campaign detailed stats types ====================
|
||||
|
||||
export interface DailyStatItem {
|
||||
date: string;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface PeriodStats {
|
||||
days: number;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface PeriodChange {
|
||||
absolute: number;
|
||||
percent: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
}
|
||||
|
||||
export interface PeriodComparison {
|
||||
current: PeriodStats;
|
||||
previous: PeriodStats;
|
||||
referrals_change: PeriodChange;
|
||||
earnings_change: PeriodChange;
|
||||
}
|
||||
|
||||
export interface CampaignReferralItem {
|
||||
id: number;
|
||||
full_name: string;
|
||||
created_at: string;
|
||||
has_paid: boolean;
|
||||
is_active: boolean;
|
||||
total_earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface PartnerCampaignDetailedStats {
|
||||
campaign_id: number;
|
||||
campaign_name: string;
|
||||
registrations_count: number;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
conversion_rate: number;
|
||||
earnings_today: number;
|
||||
earnings_week: number;
|
||||
earnings_month: number;
|
||||
daily_stats: DailyStatItem[];
|
||||
period_comparison: PeriodComparison;
|
||||
top_referrals: CampaignReferralItem[];
|
||||
}
|
||||
|
||||
// ==================== Admin-facing types ====================
|
||||
@@ -56,6 +113,7 @@ export interface AdminPartnerApplicationItem {
|
||||
telegram_channel: string | null;
|
||||
description: string | null;
|
||||
expected_monthly_referrals: number | null;
|
||||
desired_commission_percent: number | null;
|
||||
status: string;
|
||||
admin_comment: string | null;
|
||||
approved_commission_percent: number | null;
|
||||
@@ -102,7 +160,15 @@ export interface AdminPartnerDetailResponse {
|
||||
earnings_week: number;
|
||||
earnings_month: number;
|
||||
conversion_to_paid: number;
|
||||
campaigns: { id: number; name: string; start_parameter: string; is_active: boolean }[];
|
||||
campaigns: {
|
||||
id: number;
|
||||
name: string;
|
||||
start_parameter: string;
|
||||
is_active: boolean;
|
||||
registrations_count: number;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -148,6 +214,13 @@ export const partnerApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getCampaignStats: async (campaignId: number): Promise<PartnerCampaignDetailedStats> => {
|
||||
const response = await apiClient.get<PartnerCampaignDetailedStats>(
|
||||
`/cabinet/referral/partner/campaigns/${campaignId}/stats`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Admin endpoints
|
||||
getStats: async (): Promise<PartnerStats> => {
|
||||
const response = await apiClient.get<PartnerStats>('/cabinet/admin/partners/stats');
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ReferralEarning {
|
||||
reason: string;
|
||||
referral_username: string | null;
|
||||
referral_first_name: string | null;
|
||||
campaign_name: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface WheelConfig {
|
||||
can_pay_days: boolean;
|
||||
user_balance_kopeks: number;
|
||||
required_balance_kopeks: number;
|
||||
has_subscription: boolean;
|
||||
}
|
||||
|
||||
export interface SpinAvailability {
|
||||
|
||||
@@ -156,7 +156,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
};
|
||||
|
||||
const handleUseNow = () => {
|
||||
navigate('/subscription', { state: { scrollToExtend: true } });
|
||||
navigate('/subscription/purchase');
|
||||
};
|
||||
|
||||
const handleDeactivateClick = () => {
|
||||
|
||||
@@ -149,24 +149,47 @@ export function ThemeTab() {
|
||||
},
|
||||
});
|
||||
|
||||
// Update a single color in the draft and apply preview instantly
|
||||
// Throttle applyThemeColors to once per animation frame
|
||||
const rafRef = useRef(0);
|
||||
const querySyncRef = useRef(0);
|
||||
|
||||
const updateDraftColor = useCallback(
|
||||
(key: keyof ThemeColors, value: string) => {
|
||||
setDraftColors((prev) => {
|
||||
const next = { ...prev, [key]: value };
|
||||
|
||||
// Throttle CSS variable updates to 1x per frame
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
applyThemeColors(next);
|
||||
});
|
||||
|
||||
// Debounce query cache update (triggers re-renders of other components)
|
||||
clearTimeout(querySyncRef.current);
|
||||
querySyncRef.current = window.setTimeout(() => {
|
||||
queryClient.setQueryData(['theme-colors'], next);
|
||||
}, 150);
|
||||
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[queryClient],
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
clearTimeout(querySyncRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Apply a full preset and auto-save to server
|
||||
const applyPreset = useCallback(
|
||||
(colors: Partial<ThemeColors>) => {
|
||||
setDraftColors((prev) => {
|
||||
const next = { ...prev, ...colors };
|
||||
// Preset is a one-shot action — apply immediately (no throttle needed)
|
||||
applyThemeColors(next);
|
||||
queryClient.setQueryData(['theme-colors'], next);
|
||||
// Auto-save preset to server so it persists across navigation
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Suspense, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { brandingApi } from '@/api/branding';
|
||||
import type { AnimationConfig, BackgroundType } from '@/components/ui/backgrounds/types';
|
||||
@@ -6,19 +7,57 @@ import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types';
|
||||
import { backgroundComponents, prefetchBackground } from '@/components/ui/backgrounds/registry';
|
||||
|
||||
const ANIMATION_CACHE_KEY = 'cabinet_animation_config';
|
||||
const MAX_CONFIG_JSON_LENGTH = 10_000;
|
||||
|
||||
const VALID_TYPES: ReadonlySet<string> = new Set<BackgroundType>([
|
||||
'aurora',
|
||||
'sparkles',
|
||||
'vortex',
|
||||
'shooting-stars',
|
||||
'background-beams',
|
||||
'background-beams-collision',
|
||||
'gradient-animation',
|
||||
'wavy',
|
||||
'background-lines',
|
||||
'boxes',
|
||||
'meteors',
|
||||
'grid',
|
||||
'dots',
|
||||
'spotlight',
|
||||
'ripple',
|
||||
'none',
|
||||
]);
|
||||
|
||||
function validateConfig(parsed: unknown): AnimationConfig | null {
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null;
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (typeof obj.enabled !== 'boolean') return null;
|
||||
if (typeof obj.type !== 'string' || !VALID_TYPES.has(obj.type)) return null;
|
||||
if (typeof obj.opacity !== 'number') return null;
|
||||
if (typeof obj.blur !== 'number') return null;
|
||||
if (obj.settings != null && (typeof obj.settings !== 'object' || Array.isArray(obj.settings)))
|
||||
return null;
|
||||
return {
|
||||
enabled: obj.enabled,
|
||||
type: obj.type as BackgroundType,
|
||||
opacity: Math.max(0, Math.min(1, obj.opacity)),
|
||||
blur: Math.max(0, Math.min(50, obj.blur)),
|
||||
settings: (obj.settings as Record<string, unknown>) ?? {},
|
||||
reducedOnMobile: typeof obj.reducedOnMobile === 'boolean' ? obj.reducedOnMobile : true,
|
||||
};
|
||||
}
|
||||
|
||||
function getCachedConfig(): AnimationConfig | null {
|
||||
try {
|
||||
const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
|
||||
return cached ? JSON.parse(cached) : null;
|
||||
if (!cached || cached.length > MAX_CONFIG_JSON_LENGTH) return null;
|
||||
return validateConfig(JSON.parse(cached));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefetch the background JS chunk immediately based on localStorage cache.
|
||||
// This starts the download before React even renders, so by the time
|
||||
// Suspense needs the component, the chunk is already loaded.
|
||||
const cachedConfig = getCachedConfig();
|
||||
if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none') {
|
||||
prefetchBackground(cachedConfig.type);
|
||||
@@ -33,18 +72,27 @@ function setCachedConfig(config: AnimationConfig) {
|
||||
}
|
||||
|
||||
export function setCachedAnimationConfig(config: AnimationConfig) {
|
||||
setCachedConfig(config);
|
||||
const validated = validateConfig(config);
|
||||
if (validated) setCachedConfig(validated);
|
||||
}
|
||||
|
||||
function reduceMobileSettings(settings: Record<string, unknown>): Record<string, unknown> {
|
||||
const reduced = { ...settings };
|
||||
// 75% reduction (divide by 4) instead of 50% — much less GPU work
|
||||
if (typeof reduced.particleCount === 'number')
|
||||
reduced.particleCount = Math.floor((reduced.particleCount as number) / 2);
|
||||
reduced.particleCount = Math.max(20, Math.floor(reduced.particleCount / 4));
|
||||
if (typeof reduced.particleDensity === 'number')
|
||||
reduced.particleDensity = Math.floor((reduced.particleDensity as number) / 2);
|
||||
reduced.particleDensity = Math.max(50, Math.floor(reduced.particleDensity / 4));
|
||||
if (typeof reduced.number === 'number')
|
||||
reduced.number = Math.floor((reduced.number as number) / 2);
|
||||
reduced.number = Math.max(5, Math.floor(reduced.number / 4));
|
||||
if ('interactive' in reduced) reduced.interactive = false;
|
||||
if (typeof reduced.lineCount === 'number')
|
||||
reduced.lineCount = Math.max(5, Math.floor(reduced.lineCount / 2));
|
||||
if (typeof reduced.rippleCount === 'number')
|
||||
reduced.rippleCount = Math.max(2, Math.floor(reduced.rippleCount / 2));
|
||||
if (typeof reduced.count === 'number') reduced.count = Math.max(3, Math.floor(reduced.count / 2));
|
||||
if (typeof reduced.rows === 'number') reduced.rows = Math.max(4, Math.floor(reduced.rows * 0.6));
|
||||
if (typeof reduced.cols === 'number') reduced.cols = Math.max(4, Math.floor(reduced.cols * 0.6));
|
||||
return reduced;
|
||||
}
|
||||
|
||||
@@ -57,7 +105,9 @@ export function BackgroundRenderer() {
|
||||
const { data: config } = useQuery({
|
||||
queryKey: ['animation-config'],
|
||||
queryFn: async () => {
|
||||
const result = await brandingApi.getAnimationConfig();
|
||||
const raw = await brandingApi.getAnimationConfig();
|
||||
// Validate and clamp API response same as localStorage
|
||||
const result = validateConfig(raw) ?? DEFAULT_ANIMATION_CONFIG;
|
||||
setCachedConfig(result);
|
||||
return result;
|
||||
},
|
||||
@@ -83,18 +133,24 @@ export function BackgroundRenderer() {
|
||||
? reduceMobileSettings(effectiveConfig.settings)
|
||||
: effectiveConfig.settings;
|
||||
|
||||
return (
|
||||
// On mobile, cap blur to 4px max — full blur is extremely GPU-heavy
|
||||
const effectiveBlur = isMobile ? Math.min(effectiveConfig.blur, 4) : effectiveConfig.blur;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 z-0"
|
||||
className="pointer-events-none fixed inset-0"
|
||||
style={{
|
||||
zIndex: -2,
|
||||
opacity: effectiveConfig.opacity,
|
||||
filter: effectiveConfig.blur > 0 ? `blur(${effectiveConfig.blur}px)` : undefined,
|
||||
filter: effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : undefined,
|
||||
contain: 'strict',
|
||||
backfaceVisibility: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<Component settings={settings} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ interface Props {
|
||||
onOpenDeepLink: (url: string) => void;
|
||||
isTelegramWebApp: boolean;
|
||||
onGoBack: () => void;
|
||||
onOpenQR?: () => void;
|
||||
}
|
||||
|
||||
export default function InstallationGuide({
|
||||
@@ -50,6 +51,7 @@ export default function InstallationGuide({
|
||||
onOpenDeepLink,
|
||||
isTelegramWebApp,
|
||||
onGoBack,
|
||||
onOpenQR,
|
||||
}: Props) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { isLight } = useTheme();
|
||||
@@ -206,6 +208,31 @@ export default function InstallationGuide({
|
||||
<h2 className="flex-1 text-lg font-bold text-dark-100">
|
||||
{getBaseTranslation('installationGuideHeader', 'subscription.connection.title')}
|
||||
</h2>
|
||||
{appConfig.subscriptionUrl && onOpenQR && (
|
||||
<button
|
||||
onClick={() => onOpenQR()}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 text-dark-200 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 013.75 9.375v-4.5zM3.75 14.625c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5a1.125 1.125 0 01-1.125-1.125v-4.5zM13.5 4.875c0-.621.504-1.125 1.125-1.125h4.5c.621 0 1.125.504 1.125 1.125v4.5c0 .621-.504 1.125-1.125 1.125h-4.5A1.125 1.125 0 0113.5 9.375v-4.5z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.75 6.75h.75v.75h-.75v-.75zM6.75 16.5h.75v.75h-.75v-.75zM16.5 6.75h.75v.75H16.5v-.75zM13.5 13.5h.75v.75h-.75v-.75zM13.5 19.5h.75v.75h-.75v-.75zM19.5 13.5h.75v.75h-.75v-.75zM19.5 19.5h.75v.75h-.75v-.75zM16.5 16.5h3v3h-3v-3z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{availablePlatforms.length > 1 && (
|
||||
<div className="relative flex items-center">
|
||||
{currentPlatformSvg && (
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function StatsGrid({
|
||||
refLoading,
|
||||
}: StatsGridProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatAmount, currencySymbol, formatPositive } = useCurrency();
|
||||
const { formatAmount, currencySymbol } = useCurrency();
|
||||
const { isDark } = useTheme();
|
||||
const g = getGlassColors(isDark);
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function StatsGrid({
|
||||
label: t('dashboard.stats.referrals'),
|
||||
value: `${referralCount}`,
|
||||
valueColor: g.text,
|
||||
subtitle: `${formatPositive(earningsRubles)} ${currencySymbol}`,
|
||||
subtitle: `+${formatAmount(earningsRubles)} ${currencySymbol}`,
|
||||
subtitleColor: zone.mainHex,
|
||||
to: '/referral',
|
||||
icon: (color: string) => (
|
||||
|
||||
@@ -73,9 +73,11 @@ export default function SubscriptionCardActive({
|
||||
background: g.cardBg,
|
||||
border: subscription.is_trial
|
||||
? '1px solid rgba(62,219,176,0.15)'
|
||||
: `1px solid ${g.cardBorder}`,
|
||||
: isDark
|
||||
? `1px solid ${g.cardBorder}`
|
||||
: `1px solid ${zone.mainHex}25`,
|
||||
padding: '28px 28px 24px',
|
||||
boxShadow: g.shadow,
|
||||
boxShadow: isDark ? g.shadow : `0 2px 16px ${zone.mainHex}12, 0 0 0 1px ${zone.mainHex}08`,
|
||||
}}
|
||||
>
|
||||
{/* Trial shimmer border */}
|
||||
@@ -240,7 +242,8 @@ export default function SubscriptionCardActive({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device dots */}
|
||||
{/* Device indicator */}
|
||||
{subscription.device_limit <= 10 ? (
|
||||
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
|
||||
{Array.from({ length: subscription.device_limit }, (_, i) => (
|
||||
<div
|
||||
@@ -253,6 +256,24 @@ export default function SubscriptionCardActive({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-16 flex-shrink-0 items-center" aria-hidden="true">
|
||||
<div
|
||||
className="h-[6px] w-full overflow-hidden rounded-full"
|
||||
style={{ background: g.textGhost }}
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.round((connectedDevices / subscription.device_limit) * 100)}%`,
|
||||
background: zone.mainHex,
|
||||
boxShadow: `0 0 8px ${zone.mainHex}40`,
|
||||
minWidth: connectedDevices > 0 ? '4px' : '0px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HoverBorderGradient>
|
||||
)}
|
||||
|
||||
|
||||
@@ -20,8 +20,10 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa
|
||||
className="relative overflow-hidden rounded-3xl"
|
||||
style={{
|
||||
background: g.cardBg,
|
||||
border: '1px solid rgba(255,70,70,0.12)',
|
||||
boxShadow: g.shadow,
|
||||
border: isDark ? '1px solid rgba(255,70,70,0.12)' : '1px solid rgba(255,59,92,0.2)',
|
||||
boxShadow: isDark
|
||||
? g.shadow
|
||||
: '0 2px 16px rgba(255,59,92,0.1), 0 0 0 1px rgba(255,59,92,0.06)',
|
||||
padding: '28px 28px 24px',
|
||||
}}
|
||||
>
|
||||
@@ -40,18 +42,21 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa
|
||||
/>
|
||||
{/* Grid pattern */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.02]"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
|
||||
opacity: isDark ? 0.02 : 0.04,
|
||||
backgroundImage: isDark
|
||||
? `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`
|
||||
: `linear-gradient(rgba(0,0,0,0.06) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,0,0,0.06) 1px, transparent 1px)`,
|
||||
backgroundSize: '40px 40px',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-5 flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
{/* Clock icon */}
|
||||
<div
|
||||
className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[14px]"
|
||||
@@ -75,76 +80,32 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa
|
||||
<path d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold tracking-tight text-dark-50">
|
||||
{t('dashboard.expired.title')}
|
||||
{subscription.is_trial ? t('dashboard.expired.trialTitle') : t('dashboard.expired.title')}
|
||||
</h2>
|
||||
<span className="text-xs text-dark-50/35">
|
||||
{subscription.is_trial
|
||||
? t('dashboard.expired.trialSubtitle')
|
||||
: t('dashboard.expired.paidSubtitle')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badge */}
|
||||
{/* Expired date */}
|
||||
<div
|
||||
className="flex items-center gap-1 rounded-full px-3 py-1 text-[11px] font-semibold"
|
||||
style={{
|
||||
background: 'rgba(255,59,92,0.1)',
|
||||
border: '1px solid rgba(255,59,92,0.2)',
|
||||
color: '#FF3B5C',
|
||||
}}
|
||||
>
|
||||
{subscription.is_trial && (
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{subscription.is_trial ? t('subscription.trialStatus') : t('subscription.expired')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expired info grid */}
|
||||
<div
|
||||
className="mb-5 flex justify-around rounded-[14px] text-center"
|
||||
className="mb-5 flex items-center justify-center rounded-[14px]"
|
||||
style={{
|
||||
background: 'rgba(255,59,92,0.04)',
|
||||
border: '1px solid rgba(255,59,92,0.08)',
|
||||
padding: '16px 18px',
|
||||
padding: '14px 18px',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ label: t('dashboard.expired.traffic'), value: '0 GB' },
|
||||
{ label: t('dashboard.expired.devices'), value: '0' },
|
||||
{ label: t('dashboard.expired.expiredDate'), value: formattedDate },
|
||||
].map((item, i) => (
|
||||
<div key={i}>
|
||||
<div className="mb-1 font-mono text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
|
||||
{item.label}
|
||||
<div className="mb-0.5 font-mono text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
|
||||
{t('dashboard.expired.expiredDate')}
|
||||
</div>
|
||||
<div className="text-base font-bold tracking-tight text-dark-50/50">{item.value}</div>
|
||||
<div className="ml-3 text-base font-bold tracking-tight text-dark-50/50">
|
||||
{formattedDate}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2.5">
|
||||
<Link
|
||||
to="/subscription"
|
||||
state={{ scrollToExtend: true }}
|
||||
to="/subscription/purchase"
|
||||
className="flex flex-1 items-center justify-center rounded-[14px] py-3.5 text-[15px] font-semibold tracking-tight text-white transition-all duration-300"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
|
||||
@@ -154,7 +115,7 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa
|
||||
{t('dashboard.expired.renew')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/subscription"
|
||||
to="/subscription/purchase"
|
||||
className="flex items-center justify-center rounded-[14px] px-5 py-3.5 text-[15px] font-semibold tracking-tight text-dark-50/50 transition-colors duration-200"
|
||||
style={{
|
||||
background: g.innerBg,
|
||||
|
||||
@@ -33,8 +33,16 @@ export default function TrialOfferCard({
|
||||
className="relative overflow-hidden rounded-3xl text-center"
|
||||
style={{
|
||||
background: g.cardBg,
|
||||
border: `1px solid ${g.cardBorder}`,
|
||||
boxShadow: g.shadow,
|
||||
border: isDark
|
||||
? `1px solid ${g.cardBorder}`
|
||||
: isFree
|
||||
? '1px solid rgba(62,219,176,0.2)'
|
||||
: '1px solid rgba(255,184,0,0.2)',
|
||||
boxShadow: isDark
|
||||
? g.shadow
|
||||
: isFree
|
||||
? '0 2px 16px rgba(62,219,176,0.12), 0 0 0 1px rgba(62,219,176,0.06)'
|
||||
: '0 2px 16px rgba(255,184,0,0.12), 0 0 0 1px rgba(255,184,0,0.06)',
|
||||
padding: '32px 28px 28px',
|
||||
}}
|
||||
>
|
||||
@@ -55,10 +63,14 @@ export default function TrialOfferCard({
|
||||
/>
|
||||
{/* Grid pattern */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.025]"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
|
||||
opacity: isDark ? 0.025 : 0.04,
|
||||
backgroundImage: isDark
|
||||
? `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`
|
||||
: `linear-gradient(rgba(0,0,0,0.06) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(0,0,0,0.06) 1px, transparent 1px)`,
|
||||
backgroundSize: '40px 40px',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
@@ -68,10 +80,14 @@ export default function TrialOfferCard({
|
||||
<div
|
||||
className="relative mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl"
|
||||
style={{
|
||||
background: isFree
|
||||
background: isDark
|
||||
? isFree
|
||||
? 'linear-gradient(135deg, #1a3a30, #142824)'
|
||||
: 'linear-gradient(135deg, #3a3020, #282418)',
|
||||
border: isFree ? '1px solid rgba(62,219,176,0.2)' : '1px solid rgba(255,184,0,0.2)',
|
||||
: 'linear-gradient(135deg, #3a3020, #282418)'
|
||||
: isFree
|
||||
? 'linear-gradient(135deg, rgba(62,219,176,0.15), rgba(62,219,176,0.08))'
|
||||
: 'linear-gradient(135deg, rgba(255,184,0,0.15), rgba(255,184,0,0.08))',
|
||||
border: isFree ? '1px solid rgba(62,219,176,0.25)' : '1px solid rgba(255,184,0,0.25)',
|
||||
transition: 'all 0.5s ease',
|
||||
}}
|
||||
>
|
||||
@@ -229,12 +245,21 @@ export default function TrialOfferCard({
|
||||
<button
|
||||
onClick={() => !activateTrialMutation.isPending && activateTrialMutation.mutate()}
|
||||
disabled={activateTrialMutation.isPending}
|
||||
className="w-full rounded-[14px] py-4 text-base font-bold tracking-tight text-white transition-all duration-300 disabled:opacity-50"
|
||||
style={{
|
||||
className="w-full rounded-[14px] py-4 text-base font-bold tracking-tight transition-all duration-300 disabled:opacity-50"
|
||||
style={
|
||||
isDark
|
||||
? {
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(62,219,176,0.12) 0%, rgba(62,219,176,0.04) 100%)',
|
||||
border: '1px solid rgba(62,219,176,0.25)',
|
||||
}}
|
||||
color: '#fff',
|
||||
}
|
||||
: {
|
||||
background: 'linear-gradient(135deg, #3EDBB0, #2BC49A)',
|
||||
color: '#0a2a1e',
|
||||
boxShadow: '0 4px 20px rgba(62,219,176,0.25)',
|
||||
}
|
||||
}
|
||||
>
|
||||
{activateTrialMutation.isPending ? t('common.loading') : t('subscription.trial.activate')}
|
||||
</button>
|
||||
|
||||
@@ -11,9 +11,7 @@ const cardVariants = cva(
|
||||
'relative overflow-hidden',
|
||||
'border border-dark-700/40 bg-dark-900/70',
|
||||
'rounded-[var(--bento-radius)]',
|
||||
'transition-all duration-200',
|
||||
// GPU acceleration
|
||||
'transform-gpu',
|
||||
'transition-[border-color,background-color,box-shadow,transform,opacity] duration-200',
|
||||
// Glass border inset
|
||||
'shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05)]',
|
||||
],
|
||||
|
||||
@@ -242,6 +242,22 @@ export const CheckIcon = ({ className }: IconProps) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const CopyIcon = ({ className }: IconProps) => (
|
||||
<svg
|
||||
className={cn('h-4 w-4', className)}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const XIcon = ({ className }: IconProps) => (
|
||||
<svg
|
||||
className={cn('h-4 w-4', className)}
|
||||
|
||||
@@ -20,6 +20,7 @@ import CampaignBonusNotifier from '@/components/CampaignBonusNotifier';
|
||||
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
|
||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||
import TicketNotificationBell from '@/components/TicketNotificationBell';
|
||||
import { SubscriptionIcon } from '@/components/icons';
|
||||
|
||||
import { MobileBottomNav } from './MobileBottomNav';
|
||||
import { AppHeader } from './AppHeader';
|
||||
@@ -252,6 +253,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
// Desktop navigation items
|
||||
const desktopNavItems = [
|
||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
||||
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
|
||||
{ path: '/balance', label: t('nav.balance'), icon: CreditCardIcon },
|
||||
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
|
||||
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
|
||||
@@ -278,7 +280,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Animated background */}
|
||||
{/* Animated background renders via portal on document.body at z-index: -1 */}
|
||||
<BackgroundRenderer />
|
||||
|
||||
{/* Global components */}
|
||||
@@ -287,7 +289,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
<SuccessNotificationModal />
|
||||
|
||||
{/* Desktop Header */}
|
||||
<header className="fixed left-0 right-0 top-0 z-50 hidden border-b border-dark-800/50 bg-dark-950/80 backdrop-blur-xl lg:block">
|
||||
<header className="fixed left-0 right-0 top-0 z-50 hidden border-b border-dark-800/50 bg-dark-950/95 lg:block">
|
||||
<div className="mx-auto grid h-14 max-w-6xl grid-cols-[auto_1fr_auto] items-center gap-4 px-6">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-2.5" onClick={handleNavClick}>
|
||||
@@ -376,7 +378,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
toggleTheme();
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-all duration-200 hover:bg-dark-700 hover:text-accent-400',
|
||||
'rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-colors duration-200 hover:bg-dark-700 hover:text-accent-400',
|
||||
!canToggleTheme && 'pointer-events-none invisible',
|
||||
)}
|
||||
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
|
||||
@@ -390,7 +392,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
haptic.impact('light');
|
||||
logout();
|
||||
}}
|
||||
className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-all duration-200 hover:bg-dark-700 hover:text-accent-400"
|
||||
className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-colors duration-200 hover:bg-dark-700 hover:text-accent-400"
|
||||
title={t('nav.logout')}
|
||||
>
|
||||
<LogoutIcon className="h-5 w-5" />
|
||||
|
||||
200
src/components/partner/CampaignCard.tsx
Normal file
200
src/components/partner/CampaignCard.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { PartnerCampaignInfo } from '../../api/partners';
|
||||
import { PARTNER_STATS } from '../../constants/partner';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { useHaptic } from '../../platform';
|
||||
import { copyToClipboard } from '../../utils/clipboard';
|
||||
import { CampaignDetailStats } from './CampaignDetailStats';
|
||||
import { StatCard } from '../stats/StatCard';
|
||||
|
||||
const CopyIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
||||
<svg
|
||||
className={`h-5 w-5 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface CampaignCardProps {
|
||||
campaign: PartnerCampaignInfo;
|
||||
}
|
||||
|
||||
export function CampaignCard({ campaign }: CampaignCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency, formatPositive } = useCurrency();
|
||||
const haptic = useHaptic();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
|
||||
|
||||
const handleCopy = useCallback(
|
||||
async (url: string, key: string) => {
|
||||
try {
|
||||
await copyToClipboard(url);
|
||||
haptic.notification('success');
|
||||
setCopiedLink(key);
|
||||
clearTimeout(copyTimerRef.current);
|
||||
copyTimerRef.current = setTimeout(
|
||||
() => setCopiedLink(null),
|
||||
PARTNER_STATS.COPY_FEEDBACK_MS,
|
||||
);
|
||||
} catch {
|
||||
haptic.notification('error');
|
||||
}
|
||||
},
|
||||
[haptic],
|
||||
);
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
haptic.impact('light');
|
||||
setExpanded((prev) => !prev);
|
||||
}, [haptic]);
|
||||
|
||||
const botKey = `${campaign.id}-bot`;
|
||||
const webKey = `${campaign.id}-web`;
|
||||
|
||||
return (
|
||||
<div className="bento-card space-y-4">
|
||||
{/* Campaign header */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="min-w-0 truncate text-base font-semibold text-dark-100">{campaign.name}</h3>
|
||||
<button
|
||||
onClick={handleToggleExpand}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={`campaign-detail-${campaign.id}`}
|
||||
className="flex shrink-0 items-center gap-1 text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
<span>
|
||||
{expanded
|
||||
? t('referral.partner.stats.hideDetails')
|
||||
: t('referral.partner.stats.showDetails')}
|
||||
</span>
|
||||
<ChevronIcon expanded={expanded} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Basic stats -- always visible */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<StatCard
|
||||
label={t('referral.partner.stats.registrations')}
|
||||
value={campaign.registrations_count}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('referral.partner.stats.referrals')}
|
||||
value={campaign.referrals_count}
|
||||
valueClassName="text-accent-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('referral.partner.stats.earnings')}
|
||||
value={formatPositive(campaign.earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bonus info */}
|
||||
{campaign.bonus_type !== 'none' && (
|
||||
<div className="rounded-lg bg-success-500/10 p-3">
|
||||
<div className="mb-1 text-xs font-medium text-success-500">
|
||||
{t('referral.partner.campaignBonus.title')}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-success-400">
|
||||
{campaign.bonus_type === 'balance' &&
|
||||
t('referral.partner.campaignBonus.balanceDesc', {
|
||||
amount: formatWithCurrency(
|
||||
campaign.balance_bonus_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
0,
|
||||
),
|
||||
})}
|
||||
{campaign.bonus_type === 'subscription' &&
|
||||
t('referral.partner.campaignBonus.subscriptionDesc', {
|
||||
days: campaign.subscription_duration_days ?? 0,
|
||||
...(campaign.subscription_traffic_gb
|
||||
? { traffic: campaign.subscription_traffic_gb }
|
||||
: {}),
|
||||
})}
|
||||
{campaign.bonus_type === 'tariff' && t('referral.partner.campaignBonus.tariffDesc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bot link */}
|
||||
{campaign.deep_link && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||
{t('referral.partner.campaignLinks.bot')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={campaign.deep_link}
|
||||
className="input flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleCopy(campaign.deep_link!, botKey)}
|
||||
className={`btn-primary shrink-0 px-3 py-2.5 ${
|
||||
copiedLink === botKey ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
}`}
|
||||
>
|
||||
{copiedLink === botKey ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Web link */}
|
||||
{campaign.web_link && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||
{t('referral.partner.campaignLinks.web')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={campaign.web_link}
|
||||
className="input flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleCopy(campaign.web_link!, webKey)}
|
||||
className={`btn-primary shrink-0 px-3 py-2.5 ${
|
||||
copiedLink === webKey ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
}`}
|
||||
>
|
||||
{copiedLink === webKey ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded detail stats */}
|
||||
<div id={`campaign-detail-${campaign.id}`}>
|
||||
{expanded && <CampaignDetailStats campaignId={campaign.id} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/components/partner/CampaignDetailStats.tsx
Normal file
100
src/components/partner/CampaignDetailStats.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { partnerApi } from '../../api/partners';
|
||||
import { PARTNER_STATS } from '../../constants/partner';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { DailyChart } from '../stats/DailyChart';
|
||||
import { PeriodComparison } from '../stats/PeriodComparison';
|
||||
import { StatCard } from '../stats/StatCard';
|
||||
import { TopReferrals } from './TopReferrals';
|
||||
|
||||
interface CampaignDetailStatsProps {
|
||||
campaignId: number;
|
||||
}
|
||||
|
||||
export function CampaignDetailStats({ campaignId }: CampaignDetailStatsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['partner-campaign-stats', campaignId],
|
||||
queryFn: () => partnerApi.getCampaignStats(campaignId),
|
||||
staleTime: PARTNER_STATS.STATS_STALE_TIME,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3 pt-2">
|
||||
{/* Skeleton loader */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{Array.from({ length: PARTNER_STATS.SKELETON_COUNT }).map((_, i) => (
|
||||
<div key={i} className="h-16 animate-pulse rounded-xl bg-dark-800/30" />
|
||||
))}
|
||||
</div>
|
||||
<div className="h-52 animate-pulse rounded-xl bg-dark-800/30" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="pt-2 text-center">
|
||||
<div className="text-sm text-error-400">{t('referral.partner.stats.noData')}</div>
|
||||
<button onClick={() => refetch()} className="btn-secondary mt-2 px-4 py-1 text-xs">
|
||||
{t('common.retry')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pt-2">
|
||||
{/* Period earnings */}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<StatCard
|
||||
label={t('referral.partner.stats.today')}
|
||||
value={formatWithCurrency(data.earnings_today / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('referral.partner.stats.week')}
|
||||
value={formatWithCurrency(data.earnings_week / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('referral.partner.stats.month')}
|
||||
value={formatWithCurrency(data.earnings_month / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Conversion rate */}
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-dark-500">
|
||||
{t('referral.partner.stats.conversionRate')}
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-accent-400">{data.conversion_rate}%</span>
|
||||
</div>
|
||||
<div className="mt-2 h-2 overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent-500 transition-all"
|
||||
style={{
|
||||
width: `${Math.min(data.conversion_rate, PARTNER_STATS.MAX_CONVERSION_RATE)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily chart */}
|
||||
<DailyChart data={data.daily_stats} chartId={campaignId} />
|
||||
|
||||
{/* Period comparison */}
|
||||
<PeriodComparison data={data.period_comparison} />
|
||||
|
||||
{/* Top referrals */}
|
||||
<TopReferrals referrals={data.top_referrals} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
src/components/partner/TopReferrals.tsx
Normal file
70
src/components/partner/TopReferrals.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { CampaignReferralItem } from '../../api/partners';
|
||||
import { PARTNER_STATS } from '../../constants/partner';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
|
||||
interface TopReferralsProps {
|
||||
referrals: CampaignReferralItem[];
|
||||
}
|
||||
|
||||
interface StatusBadgeProps {
|
||||
hasPaid: boolean;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function StatusBadge({ hasPaid, isActive }: StatusBadgeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isActive) {
|
||||
return <span className="badge-success">{t('referral.partner.stats.active')}</span>;
|
||||
}
|
||||
if (hasPaid) {
|
||||
return <span className="badge-info">{t('referral.partner.stats.paid')}</span>;
|
||||
}
|
||||
return <span className="badge-neutral">{t('referral.partner.stats.pending')}</span>;
|
||||
}
|
||||
|
||||
export function TopReferrals({ referrals }: TopReferralsProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
if (referrals.length === 0) {
|
||||
return (
|
||||
<div className="bento-card py-6 text-center">
|
||||
<div className="text-sm text-dark-400">{t('referral.partner.stats.noReferrals')}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">
|
||||
{t('referral.partner.stats.topReferrals')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{referrals.map((ref) => (
|
||||
<div
|
||||
key={ref.id}
|
||||
className="flex items-center justify-between rounded-xl border border-dark-700/30 bg-dark-800/30 p-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate text-sm font-medium text-dark-100">
|
||||
{ref.full_name}
|
||||
</span>
|
||||
<StatusBadge hasPaid={ref.has_paid} isActive={ref.is_active} />
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-500">
|
||||
{new Date(ref.created_at).toLocaleDateString(i18n.language)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-success-400">
|
||||
{formatWithCurrency(ref.total_earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
100
src/components/sales-stats/AddonsTab.tsx
Normal file
100
src/components/sales-stats/AddonsTab.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { StatCard } from '../stats';
|
||||
|
||||
import { DualAreaChart } from './DualAreaChart';
|
||||
import { SimpleBarChart } from './SimpleBarChart';
|
||||
|
||||
interface AddonsTabProps {
|
||||
params: SalesStatsParams;
|
||||
}
|
||||
|
||||
export function AddonsTab({ params }: AddonsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['sales-stats', 'addons', params],
|
||||
queryFn: () => salesStatsApi.getAddons(params),
|
||||
staleTime: SALES_STATS.STALE_TIME,
|
||||
});
|
||||
|
||||
const dailyChartData = useMemo(() => {
|
||||
if (!data) return [];
|
||||
const trafficByDate = new Map(data.daily.map((d) => [d.date, d.count]));
|
||||
const deviceByDate = new Map(data.daily_devices.map((d) => [d.date, d.count]));
|
||||
const allDates = Array.from(new Set([...trafficByDate.keys(), ...deviceByDate.keys()])).sort();
|
||||
return allDates.map((date) => ({
|
||||
date,
|
||||
series1: trafficByDate.get(date) ?? 0,
|
||||
series2: deviceByDate.get(date) ?? 0,
|
||||
}));
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
|
||||
}
|
||||
|
||||
const packageBarData = data.by_package.map((item) => ({
|
||||
name: `${item.traffic_gb} GB`,
|
||||
value: item.count,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<StatCard
|
||||
label={t('admin.salesStats.addons.totalPurchases')}
|
||||
value={data.total_purchases}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.addons.totalGb')}
|
||||
value={`${data.total_gb_purchased} GB`}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.addons.revenue')}
|
||||
value={formatWithCurrency(data.addon_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.addons.devicePurchases')}
|
||||
value={data.device_purchases}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.addons.deviceRevenue')}
|
||||
value={formatWithCurrency(data.device_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<SimpleBarChart data={packageBarData} title={t('admin.salesStats.addons.byPackage')} />
|
||||
<DualAreaChart
|
||||
data={dailyChartData}
|
||||
title={t('admin.salesStats.addons.dailyChart')}
|
||||
chartId="addons-daily"
|
||||
series1Label={t('admin.salesStats.addons.trafficPurchases')}
|
||||
series2Label={t('admin.salesStats.addons.devicePurchasesDaily')}
|
||||
series1Color={SALES_STATS.BAR_COLORS[5]}
|
||||
series2Color={SALES_STATS.BAR_COLORS[3]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
src/components/sales-stats/DepositsTab.tsx
Normal file
128
src/components/sales-stats/DepositsTab.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { StatCard } from '../stats';
|
||||
|
||||
import { MultiSeriesAreaChart } from './MultiSeriesAreaChart';
|
||||
import { SimpleAreaChart } from './SimpleAreaChart';
|
||||
import { SimpleBarChart } from './SimpleBarChart';
|
||||
|
||||
interface DepositsTabProps {
|
||||
params: SalesStatsParams;
|
||||
}
|
||||
|
||||
const METHOD_LABELS: Record<string, string> = {
|
||||
telegram_stars: 'Telegram Stars',
|
||||
tribute: 'Tribute',
|
||||
yookassa: 'YooKassa',
|
||||
cryptobot: 'CryptoBot',
|
||||
heleket: 'Heleket',
|
||||
mulenpay: 'Mulenpay',
|
||||
pal24: 'Pal24',
|
||||
wata: 'Wata',
|
||||
platega: 'Platega',
|
||||
cloudpayments: 'CloudPayments',
|
||||
freekassa: 'FreeKassa',
|
||||
kassa_ai: 'Kassa AI',
|
||||
};
|
||||
|
||||
export function DepositsTab({ params }: DepositsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['sales-stats', 'deposits', params],
|
||||
queryFn: () => salesStatsApi.getDeposits(params),
|
||||
staleTime: SALES_STATS.STALE_TIME,
|
||||
});
|
||||
|
||||
const formatValue = useCallback((v: number) => formatWithCurrency(v), [formatWithCurrency]);
|
||||
|
||||
const methodBarData = useMemo(
|
||||
() =>
|
||||
data?.by_method.map((item) => ({
|
||||
name: METHOD_LABELS[item.method] || item.method,
|
||||
value: item.amount_kopeks / SALES_STATS.KOPEKS_DIVISOR,
|
||||
})) ?? [],
|
||||
[data?.by_method],
|
||||
);
|
||||
|
||||
const dailyData = useMemo(
|
||||
() =>
|
||||
data?.daily.map((item) => ({
|
||||
date: item.date,
|
||||
value: item.amount_kopeks / SALES_STATS.KOPEKS_DIVISOR,
|
||||
})) ?? [],
|
||||
[data?.daily],
|
||||
);
|
||||
|
||||
const dailyByMethodData = useMemo(
|
||||
() =>
|
||||
data?.daily_by_method.map((i) => ({
|
||||
date: i.date,
|
||||
key: METHOD_LABELS[i.method] || i.method,
|
||||
value: i.amount_kopeks / SALES_STATS.KOPEKS_DIVISOR,
|
||||
})) ?? [],
|
||||
[data?.daily_by_method],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard
|
||||
label={t('admin.salesStats.deposits.totalDeposits')}
|
||||
value={data.total_deposits}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.deposits.totalAmount')}
|
||||
value={formatWithCurrency(data.total_amount_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.deposits.avgDeposit')}
|
||||
value={formatWithCurrency(data.avg_deposit_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SimpleBarChart
|
||||
data={methodBarData}
|
||||
title={t('admin.salesStats.deposits.byMethod')}
|
||||
valueFormatter={formatValue}
|
||||
/>
|
||||
|
||||
<SimpleAreaChart
|
||||
data={dailyData}
|
||||
title={t('admin.salesStats.deposits.dailyChart')}
|
||||
chartId="deposits-daily"
|
||||
valueLabel={t('admin.salesStats.deposits.revenue')}
|
||||
/>
|
||||
|
||||
<MultiSeriesAreaChart
|
||||
data={dailyByMethodData}
|
||||
title={t('admin.salesStats.deposits.dailyByMethod')}
|
||||
chartId="deposits-daily-by-method"
|
||||
valueLabel={t('admin.salesStats.deposits.revenue')}
|
||||
valueFormatter={formatValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/components/sales-stats/DonutChart.tsx
Normal file
83
src/components/sales-stats/DonutChart.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useChartColors } from '../../hooks/useChartColors';
|
||||
|
||||
interface DonutChartProps {
|
||||
data: { name: string; value: number; color?: string }[];
|
||||
title: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function DonutChart({
|
||||
data,
|
||||
title,
|
||||
height = SALES_STATS.CHART.PIE_HEIGHT,
|
||||
}: DonutChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useChartColors();
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
|
||||
{t('common.noData')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={55}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.name}
|
||||
fill={entry.color || SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: colors.tooltipBg,
|
||||
border: `1px solid ${colors.tooltipBorder}`,
|
||||
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
|
||||
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
|
||||
}}
|
||||
labelStyle={{ color: colors.label }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-2 flex flex-wrap justify-center gap-3">
|
||||
{data.map((entry, index) => (
|
||||
<div key={entry.name} className="flex items-center gap-1.5 text-xs text-dark-400">
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
entry.color || SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length],
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{entry.name}: {entry.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
src/components/sales-stats/DualAreaChart.tsx
Normal file
151
src/components/sales-stats/DualAreaChart.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useChartColors } from '../../hooks/useChartColors';
|
||||
|
||||
interface DualAreaChartProps {
|
||||
data: { date: string; series1: number; series2: number }[];
|
||||
title: string;
|
||||
chartId: string;
|
||||
series1Label: string;
|
||||
series2Label: string;
|
||||
series1Color?: string;
|
||||
series2Color?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function DualAreaChart({
|
||||
data,
|
||||
title,
|
||||
chartId,
|
||||
series1Label,
|
||||
series2Label,
|
||||
series1Color,
|
||||
series2Color,
|
||||
height = SALES_STATS.CHART.HEIGHT,
|
||||
}: DualAreaChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const colors = useChartColors();
|
||||
const color1 = series1Color || colors.referrals;
|
||||
const color2 = series2Color || colors.earnings;
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
data.map((item) => ({
|
||||
...item,
|
||||
label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
})),
|
||||
[data, i18n.language],
|
||||
);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
|
||||
{t('common.noData')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={chartData} margin={SALES_STATS.CHART.MARGIN}>
|
||||
<defs>
|
||||
<linearGradient id={`gradient-s1-${chartId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset={SALES_STATS.GRADIENT.START_OFFSET}
|
||||
stopColor={color1}
|
||||
stopOpacity={SALES_STATS.GRADIENT.START_OPACITY}
|
||||
/>
|
||||
<stop
|
||||
offset={SALES_STATS.GRADIENT.END_OFFSET}
|
||||
stopColor={color1}
|
||||
stopOpacity={SALES_STATS.GRADIENT.END_OPACITY}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id={`gradient-s2-${chartId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset={SALES_STATS.GRADIENT.START_OFFSET}
|
||||
stopColor={color2}
|
||||
stopOpacity={SALES_STATS.GRADIENT.START_OPACITY}
|
||||
/>
|
||||
<stop
|
||||
offset={SALES_STATS.GRADIENT.END_OFFSET}
|
||||
stopColor={color2}
|
||||
stopOpacity={SALES_STATS.GRADIENT.END_OPACITY}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray={SALES_STATS.GRID_DASH} stroke={colors.grid} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={SALES_STATS.AXIS.WIDTH}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: colors.tooltipBg,
|
||||
border: `1px solid ${colors.tooltipBorder}`,
|
||||
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
|
||||
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
|
||||
}}
|
||||
labelStyle={{ color: colors.label }}
|
||||
formatter={(value: number | undefined, name: string | undefined) => {
|
||||
const displayValue = value ?? 0;
|
||||
if (name === 'series1') return [displayValue, series1Label];
|
||||
return [displayValue, series2Label];
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
formatter={(value: string) => {
|
||||
if (value === 'series1') return series1Label;
|
||||
return series2Label;
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="series1"
|
||||
name="series1"
|
||||
stroke={color1}
|
||||
fill={`url(#gradient-s1-${chartId})`}
|
||||
strokeWidth={SALES_STATS.STROKE_WIDTH}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="series2"
|
||||
name="series2"
|
||||
stroke={color2}
|
||||
fill={`url(#gradient-s2-${chartId})`}
|
||||
strokeWidth={SALES_STATS.STROKE_WIDTH}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/components/sales-stats/MultiSeriesAreaChart.tsx
Normal file
157
src/components/sales-stats/MultiSeriesAreaChart.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useChartColors } from '../../hooks/useChartColors';
|
||||
|
||||
interface MultiSeriesAreaChartProps {
|
||||
data: { date: string; key: string; value: number }[];
|
||||
title: string;
|
||||
chartId: string;
|
||||
valueLabel?: string;
|
||||
valueFormatter?: (value: number) => string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function MultiSeriesAreaChart({
|
||||
data,
|
||||
title,
|
||||
chartId,
|
||||
valueLabel,
|
||||
valueFormatter,
|
||||
height = SALES_STATS.CHART.HEIGHT,
|
||||
}: MultiSeriesAreaChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const colors = useChartColors();
|
||||
|
||||
// Pivot flat data into { label, [key1]: value, [key2]: value, ... }
|
||||
const { chartData, seriesKeys } = useMemo(() => {
|
||||
const dateMap = new Map<string, Record<string, number>>();
|
||||
const keySet = new Set<string>();
|
||||
|
||||
for (const item of data) {
|
||||
keySet.add(item.key);
|
||||
const existing = dateMap.get(item.date) || {};
|
||||
existing[item.key] = (existing[item.key] || 0) + item.value;
|
||||
dateMap.set(item.date, existing);
|
||||
}
|
||||
|
||||
const keys = Array.from(keySet).sort();
|
||||
const sortedDates = Array.from(dateMap.keys()).sort();
|
||||
const pivoted = sortedDates.map((date) => {
|
||||
const row: Record<string, string | number> = {
|
||||
date,
|
||||
label: new Date(date + 'T00:00:00').toLocaleDateString(i18n.language, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
};
|
||||
const values = dateMap.get(date) || {};
|
||||
for (const key of keys) {
|
||||
row[key] = values[key] || 0;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
return { chartData: pivoted, seriesKeys: keys };
|
||||
}, [data, i18n.language]);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
|
||||
{t('common.noData')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={chartData} margin={SALES_STATS.CHART.MARGIN}>
|
||||
<defs>
|
||||
{seriesKeys.map((key, i) => {
|
||||
const color = SALES_STATS.BAR_COLORS[i % SALES_STATS.BAR_COLORS.length];
|
||||
return (
|
||||
<linearGradient
|
||||
key={key}
|
||||
id={`gradient-${chartId}-${i}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop
|
||||
offset={SALES_STATS.GRADIENT.START_OFFSET}
|
||||
stopColor={color}
|
||||
stopOpacity={SALES_STATS.GRADIENT.START_OPACITY}
|
||||
/>
|
||||
<stop
|
||||
offset={SALES_STATS.GRADIENT.END_OFFSET}
|
||||
stopColor={color}
|
||||
stopOpacity={SALES_STATS.GRADIENT.END_OPACITY}
|
||||
/>
|
||||
</linearGradient>
|
||||
);
|
||||
})}
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray={SALES_STATS.GRID_DASH} stroke={colors.grid} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={SALES_STATS.AXIS.WIDTH}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: colors.tooltipBg,
|
||||
border: `1px solid ${colors.tooltipBorder}`,
|
||||
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
|
||||
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
|
||||
}}
|
||||
labelStyle={{ color: colors.label }}
|
||||
formatter={(value: number | undefined, name: string | undefined) => [
|
||||
valueFormatter ? valueFormatter(value ?? 0) : (value ?? 0),
|
||||
name || valueLabel || '',
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
{seriesKeys.map((key, i) => {
|
||||
const color = SALES_STATS.BAR_COLORS[i % SALES_STATS.BAR_COLORS.length];
|
||||
return (
|
||||
<Area
|
||||
key={key}
|
||||
type="monotone"
|
||||
dataKey={key}
|
||||
name={key}
|
||||
stroke={color}
|
||||
fill={`url(#gradient-${chartId}-${i})`}
|
||||
strokeWidth={SALES_STATS.STROKE_WIDTH}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
src/components/sales-stats/PeriodSelector.tsx
Normal file
89
src/components/sales-stats/PeriodSelector.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
|
||||
interface PeriodSelectorProps {
|
||||
value: { days?: number; startDate?: string; endDate?: string };
|
||||
onChange: (period: { days?: number; startDate?: string; endDate?: string }) => void;
|
||||
}
|
||||
|
||||
export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
|
||||
const presetLabels: Record<number, string> = {
|
||||
7: t('admin.salesStats.period.week'),
|
||||
30: t('admin.salesStats.period.month'),
|
||||
90: t('admin.salesStats.period.quarter'),
|
||||
0: t('admin.salesStats.period.all'),
|
||||
};
|
||||
|
||||
const handlePreset = (days: number) => {
|
||||
setShowCustom(false);
|
||||
onChange({ days });
|
||||
};
|
||||
|
||||
const handleCustomToggle = () => {
|
||||
setShowCustom((prev) => !prev);
|
||||
};
|
||||
|
||||
const handleDateChange = (field: 'startDate' | 'endDate', dateStr: string) => {
|
||||
onChange({
|
||||
...value,
|
||||
days: undefined,
|
||||
[field]: dateStr,
|
||||
});
|
||||
};
|
||||
|
||||
const isPresetActive = (days: number) => !showCustom && value.days === days;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{SALES_STATS.PERIOD_PRESETS.map((days) => (
|
||||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
onClick={() => handlePreset(days)}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
isPresetActive(days)
|
||||
? 'bg-accent-500/20 text-accent-400'
|
||||
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
|
||||
}`}
|
||||
>
|
||||
{presetLabels[days]}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomToggle}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
showCustom
|
||||
? 'bg-accent-500/20 text-accent-400'
|
||||
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
|
||||
}`}
|
||||
>
|
||||
{t('admin.salesStats.period.custom')}
|
||||
</button>
|
||||
|
||||
{showCustom && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={value.startDate || ''}
|
||||
onChange={(e) => handleDateChange('startDate', e.target.value)}
|
||||
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
|
||||
/>
|
||||
<span className="text-dark-500">{'\u2014'}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={value.endDate || ''}
|
||||
onChange={(e) => handleDateChange('endDate', e.target.value)}
|
||||
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/sales-stats/RenewalsTab.tsx
Normal file
102
src/components/sales-stats/RenewalsTab.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { StatCard } from '../stats';
|
||||
import { TREND_STYLES } from '../stats/constants';
|
||||
|
||||
import { SimpleAreaChart } from './SimpleAreaChart';
|
||||
|
||||
interface RenewalsTabProps {
|
||||
params: SalesStatsParams;
|
||||
}
|
||||
|
||||
export function RenewalsTab({ params }: RenewalsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['sales-stats', 'renewals', params],
|
||||
queryFn: () => salesStatsApi.getRenewals(params),
|
||||
staleTime: SALES_STATS.STALE_TIME,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
|
||||
}
|
||||
|
||||
const dailyData = data.daily.map((item) => ({
|
||||
date: item.date,
|
||||
value: item.count,
|
||||
}));
|
||||
|
||||
const trendStyle = TREND_STYLES[data.change.trend] ?? TREND_STYLES.stable;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard label={t('admin.salesStats.renewals.total')} value={data.total_renewals} />
|
||||
<StatCard
|
||||
label={t('admin.salesStats.renewals.rate')}
|
||||
value={`${data.renewal_rate}%`}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.renewals.revenue')}
|
||||
value={formatWithCurrency(data.total_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">
|
||||
{t('admin.salesStats.renewals.comparison')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.salesStats.renewals.currentPeriod')}
|
||||
</div>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-base font-semibold text-dark-100 sm:text-lg">
|
||||
{data.current_period.count}
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${trendStyle.className}`}>
|
||||
{trendStyle.arrow} {Math.abs(data.change.percent)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.salesStats.renewals.previousPeriod')}
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold text-dark-400 sm:text-lg">
|
||||
{data.previous_period.count}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SimpleAreaChart
|
||||
data={dailyData}
|
||||
title={t('admin.salesStats.renewals.dailyChart')}
|
||||
chartId="renewals-daily"
|
||||
valueLabel={t('admin.salesStats.renewals.renewals')}
|
||||
color={SALES_STATS.BAR_COLORS[2]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
src/components/sales-stats/SalesTab.tsx
Normal file
98
src/components/sales-stats/SalesTab.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { StatCard } from '../stats';
|
||||
|
||||
import { DonutChart } from './DonutChart';
|
||||
import { MultiSeriesAreaChart } from './MultiSeriesAreaChart';
|
||||
import { SimpleAreaChart } from './SimpleAreaChart';
|
||||
import { SimpleBarChart } from './SimpleBarChart';
|
||||
|
||||
interface SalesTabProps {
|
||||
params: SalesStatsParams;
|
||||
}
|
||||
|
||||
export function SalesTab({ params }: SalesTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['sales-stats', 'sales', params],
|
||||
queryFn: () => salesStatsApi.getSales(params),
|
||||
staleTime: SALES_STATS.STALE_TIME,
|
||||
});
|
||||
|
||||
const dailyByTariffData = useMemo(
|
||||
() =>
|
||||
data?.daily_by_tariff.map((i) => ({ date: i.date, key: i.tariff_name, value: i.count })) ??
|
||||
[],
|
||||
[data?.daily_by_tariff],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
|
||||
}
|
||||
|
||||
const tariffBarData = data.by_tariff.map((item) => ({
|
||||
name: item.tariff_name,
|
||||
value: item.count,
|
||||
}));
|
||||
|
||||
const periodPieData = data.by_period.map((item) => ({
|
||||
name: `${item.period_days} ${t('admin.trafficUsage.days')}`,
|
||||
value: item.count,
|
||||
}));
|
||||
|
||||
const dailyData = data.daily.map((item) => ({
|
||||
date: item.date,
|
||||
value: item.revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<StatCard label={t('admin.salesStats.sales.totalSales')} value={data.total_sales} />
|
||||
<StatCard
|
||||
label={t('admin.salesStats.sales.avgOrder')}
|
||||
value={formatWithCurrency(data.avg_order_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard label={t('admin.salesStats.sales.topTariff')} value={data.top_tariff_name} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<SimpleBarChart data={tariffBarData} title={t('admin.salesStats.sales.byTariff')} />
|
||||
<DonutChart data={periodPieData} title={t('admin.salesStats.sales.byPeriod')} />
|
||||
</div>
|
||||
|
||||
<SimpleAreaChart
|
||||
data={dailyData}
|
||||
title={t('admin.salesStats.sales.dailyChart')}
|
||||
chartId="sales-daily"
|
||||
valueLabel={t('admin.salesStats.summary.revenue')}
|
||||
/>
|
||||
|
||||
<MultiSeriesAreaChart
|
||||
data={dailyByTariffData}
|
||||
title={t('admin.salesStats.sales.dailyByTariff')}
|
||||
chartId="sales-daily-by-tariff"
|
||||
valueLabel={t('admin.salesStats.sales.subscriptions')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src/components/sales-stats/SimpleAreaChart.tsx
Normal file
114
src/components/sales-stats/SimpleAreaChart.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useChartColors } from '../../hooks/useChartColors';
|
||||
|
||||
interface SimpleAreaChartProps {
|
||||
data: { date: string; value: number }[];
|
||||
title: string;
|
||||
color?: string;
|
||||
chartId: string;
|
||||
valueLabel?: string;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export function SimpleAreaChart({
|
||||
data,
|
||||
title,
|
||||
color,
|
||||
chartId,
|
||||
valueLabel,
|
||||
height = SALES_STATS.CHART.HEIGHT,
|
||||
}: SimpleAreaChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const colors = useChartColors();
|
||||
const strokeColor = color || colors.earnings;
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
data.map((item) => ({
|
||||
...item,
|
||||
label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
})),
|
||||
[data, i18n.language],
|
||||
);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
|
||||
{t('common.noData')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<AreaChart data={chartData} margin={SALES_STATS.CHART.MARGIN}>
|
||||
<defs>
|
||||
<linearGradient id={`gradient-${chartId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset={SALES_STATS.GRADIENT.START_OFFSET}
|
||||
stopColor={strokeColor}
|
||||
stopOpacity={SALES_STATS.GRADIENT.START_OPACITY}
|
||||
/>
|
||||
<stop
|
||||
offset={SALES_STATS.GRADIENT.END_OFFSET}
|
||||
stopColor={strokeColor}
|
||||
stopOpacity={SALES_STATS.GRADIENT.END_OPACITY}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray={SALES_STATS.GRID_DASH} stroke={colors.grid} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={SALES_STATS.AXIS.WIDTH}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: colors.tooltipBg,
|
||||
border: `1px solid ${colors.tooltipBorder}`,
|
||||
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
|
||||
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
|
||||
}}
|
||||
labelStyle={{ color: colors.label }}
|
||||
formatter={(value: number | undefined) => [value ?? 0, valueLabel || '']}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="value"
|
||||
stroke={strokeColor}
|
||||
fill={`url(#gradient-${chartId})`}
|
||||
strokeWidth={SALES_STATS.STROKE_WIDTH}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
src/components/sales-stats/SimpleBarChart.tsx
Normal file
98
src/components/sales-stats/SimpleBarChart.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { useChartColors } from '../../hooks/useChartColors';
|
||||
|
||||
interface SimpleBarChartProps {
|
||||
data: { name: string; value: number; color?: string }[];
|
||||
title: string;
|
||||
height?: number;
|
||||
valueFormatter?: (value: number) => string;
|
||||
}
|
||||
|
||||
const BAR_CHART_MARGIN = {
|
||||
...SALES_STATS.CHART.MARGIN,
|
||||
bottom: 40,
|
||||
};
|
||||
|
||||
export function SimpleBarChart({
|
||||
data,
|
||||
title,
|
||||
height = SALES_STATS.CHART.HEIGHT,
|
||||
valueFormatter,
|
||||
}: SimpleBarChartProps) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useChartColors();
|
||||
|
||||
const adjustedHeight = useMemo(() => height + 35, [height]);
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
|
||||
{t('common.noData')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||
<ResponsiveContainer width="100%" height={adjustedHeight}>
|
||||
<BarChart data={data} margin={BAR_CHART_MARGIN}>
|
||||
<CartesianGrid strokeDasharray={SALES_STATS.GRID_DASH} stroke={colors.grid} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
angle={-30}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={SALES_STATS.AXIS.WIDTH}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: colors.grid, fillOpacity: 0.3 }}
|
||||
contentStyle={{
|
||||
backgroundColor: colors.tooltipBg,
|
||||
border: `1px solid ${colors.tooltipBorder}`,
|
||||
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
|
||||
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
|
||||
}}
|
||||
labelStyle={{ color: colors.label }}
|
||||
formatter={(value: number | undefined) => [
|
||||
valueFormatter ? valueFormatter(value ?? 0) : (value ?? 0),
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.name}
|
||||
fill={entry.color || SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
src/components/sales-stats/TrialsTab.tsx
Normal file
91
src/components/sales-stats/TrialsTab.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||
import { SALES_STATS } from '../../constants/salesStats';
|
||||
import { StatCard } from '../stats';
|
||||
|
||||
import { DonutChart } from './DonutChart';
|
||||
import { DualAreaChart } from './DualAreaChart';
|
||||
|
||||
interface TrialsTabProps {
|
||||
params: SalesStatsParams;
|
||||
}
|
||||
|
||||
const PROVIDER_LABELS: Record<string, string> = {
|
||||
telegram: 'Telegram',
|
||||
email: 'Email',
|
||||
vk: 'VK',
|
||||
yandex: 'Yandex',
|
||||
google: 'Google',
|
||||
discord: 'Discord',
|
||||
};
|
||||
|
||||
export function TrialsTab({ params }: TrialsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['sales-stats', 'trials', params],
|
||||
queryFn: () => salesStatsApi.getTrials(params),
|
||||
staleTime: SALES_STATS.STALE_TIME,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
|
||||
}
|
||||
|
||||
const pieData = data.by_provider.map((item) => ({
|
||||
name: PROVIDER_LABELS[item.provider] || item.provider,
|
||||
value: item.count,
|
||||
color: SALES_STATS.PROVIDER_COLORS[item.provider as keyof typeof SALES_STATS.PROVIDER_COLORS],
|
||||
}));
|
||||
|
||||
const dualData = data.daily.map((item) => ({
|
||||
date: item.date,
|
||||
series1: item.registrations,
|
||||
series2: item.trials,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard
|
||||
label={t('admin.salesStats.trials.totalRegistrations')}
|
||||
value={data.total_registrations}
|
||||
/>
|
||||
<StatCard label={t('admin.salesStats.trials.total')} value={data.total_trials} />
|
||||
<StatCard
|
||||
label={t('admin.salesStats.trials.conversion')}
|
||||
value={`${data.conversion_rate}%`}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.trials.avgDuration')}
|
||||
value={`${data.avg_trial_duration_days} ${t('admin.trafficUsage.days')}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<DonutChart data={pieData} title={t('admin.salesStats.trials.byProvider')} />
|
||||
<DualAreaChart
|
||||
data={dualData}
|
||||
title={t('admin.salesStats.trials.dailyChart')}
|
||||
chartId="trials-daily"
|
||||
series1Label={t('admin.salesStats.trials.totalRegistrations')}
|
||||
series2Label={t('admin.salesStats.trials.trialsIssued')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/components/sales-stats/index.ts
Normal file
11
src/components/sales-stats/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { AddonsTab } from './AddonsTab';
|
||||
export { DepositsTab } from './DepositsTab';
|
||||
export { DonutChart } from './DonutChart';
|
||||
export { DualAreaChart } from './DualAreaChart';
|
||||
export { MultiSeriesAreaChart } from './MultiSeriesAreaChart';
|
||||
export { PeriodSelector } from './PeriodSelector';
|
||||
export { RenewalsTab } from './RenewalsTab';
|
||||
export { SalesTab } from './SalesTab';
|
||||
export { SimpleAreaChart } from './SimpleAreaChart';
|
||||
export { SimpleBarChart } from './SimpleBarChart';
|
||||
export { TrialsTab } from './TrialsTab';
|
||||
144
src/components/stats/DailyChart.tsx
Normal file
144
src/components/stats/DailyChart.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import type { DailyStatItem } from './types';
|
||||
import { PARTNER_STATS } from '../../constants/partner';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
import { useChartColors } from '../../hooks/useChartColors';
|
||||
|
||||
interface DailyChartProps {
|
||||
data: DailyStatItem[];
|
||||
chartId: string | number;
|
||||
title?: string;
|
||||
earningsLabel?: string;
|
||||
countLabel?: string;
|
||||
}
|
||||
|
||||
interface ChartDataItem extends DailyStatItem {
|
||||
earnings_display: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function DailyChart({ data, chartId, title, earningsLabel, countLabel }: DailyChartProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const colors = useChartColors();
|
||||
|
||||
const resolvedTitle = title ?? t('referral.partner.stats.dailyChart');
|
||||
const resolvedEarningsLabel = earningsLabel ?? t('referral.partner.stats.earnings');
|
||||
const resolvedCountLabel = countLabel ?? t('referral.partner.stats.referrals');
|
||||
|
||||
const chartData: ChartDataItem[] = useMemo(
|
||||
() =>
|
||||
data.map((item) => ({
|
||||
...item,
|
||||
earnings_display: item.earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
})),
|
||||
[data, i18n.language],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{resolvedTitle}</h4>
|
||||
<ResponsiveContainer width="100%" height={PARTNER_STATS.CHART.HEIGHT}>
|
||||
<AreaChart data={chartData} margin={PARTNER_STATS.CHART.MARGIN}>
|
||||
<defs>
|
||||
<linearGradient id={`earningsGradient-${chartId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset={PARTNER_STATS.GRADIENT.START_OFFSET}
|
||||
stopColor={colors.earnings}
|
||||
stopOpacity={PARTNER_STATS.GRADIENT.START_OPACITY}
|
||||
/>
|
||||
<stop
|
||||
offset={PARTNER_STATS.GRADIENT.END_OFFSET}
|
||||
stopColor={colors.earnings}
|
||||
stopOpacity={PARTNER_STATS.GRADIENT.END_OPACITY}
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient id={`referralsGradient-${chartId}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset={PARTNER_STATS.GRADIENT.START_OFFSET}
|
||||
stopColor={colors.referrals}
|
||||
stopOpacity={PARTNER_STATS.GRADIENT.START_OPACITY}
|
||||
/>
|
||||
<stop
|
||||
offset={PARTNER_STATS.GRADIENT.END_OFFSET}
|
||||
stopColor={colors.referrals}
|
||||
stopOpacity={PARTNER_STATS.GRADIENT.END_OPACITY}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray={PARTNER_STATS.GRID_DASH} stroke={colors.grid} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: colors.tick, fontSize: PARTNER_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="earnings"
|
||||
orientation="right"
|
||||
tick={{ fill: colors.earnings, fontSize: PARTNER_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={PARTNER_STATS.AXIS.EARNINGS_WIDTH}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="referrals"
|
||||
orientation="left"
|
||||
tick={{ fill: colors.referrals, fontSize: PARTNER_STATS.AXIS.TICK_FONT_SIZE }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={PARTNER_STATS.AXIS.REFERRALS_WIDTH}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: colors.tooltipBg,
|
||||
border: `1px solid ${colors.tooltipBorder}`,
|
||||
borderRadius: PARTNER_STATS.TOOLTIP.BORDER_RADIUS,
|
||||
fontSize: PARTNER_STATS.TOOLTIP.FONT_SIZE,
|
||||
}}
|
||||
labelStyle={{ color: colors.label }}
|
||||
formatter={(value: number | undefined, name: string | undefined) => {
|
||||
const displayValue = value ?? 0;
|
||||
if (name === 'earnings_display') {
|
||||
return [formatWithCurrency(displayValue), resolvedEarningsLabel];
|
||||
}
|
||||
return [displayValue, resolvedCountLabel];
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="earnings"
|
||||
type="monotone"
|
||||
dataKey="earnings_display"
|
||||
stroke={colors.earnings}
|
||||
fill={`url(#earningsGradient-${chartId})`}
|
||||
strokeWidth={PARTNER_STATS.STROKE_WIDTH}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="referrals"
|
||||
type="monotone"
|
||||
dataKey="referrals_count"
|
||||
stroke={colors.referrals}
|
||||
fill={`url(#referralsGradient-${chartId})`}
|
||||
strokeWidth={PARTNER_STATS.STROKE_WIDTH}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
src/components/stats/PeriodComparison.tsx
Normal file
78
src/components/stats/PeriodComparison.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { PeriodComparison as PeriodComparisonType } from './types';
|
||||
import { TREND_STYLES } from './constants';
|
||||
import { PARTNER_STATS } from '../../constants/partner';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
|
||||
interface PeriodComparisonProps {
|
||||
data: PeriodComparisonType;
|
||||
title?: string;
|
||||
countLabel?: string;
|
||||
earningsLabel?: string;
|
||||
comparisonLabel?: string;
|
||||
}
|
||||
|
||||
interface TrendBadgeProps {
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
percent: number;
|
||||
}
|
||||
|
||||
function TrendBadge({ trend, percent }: TrendBadgeProps) {
|
||||
const style = TREND_STYLES[trend];
|
||||
return (
|
||||
<span className={`text-sm font-medium ${style.className}`}>
|
||||
{style.arrow} {Math.abs(percent)}%
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PeriodComparison({
|
||||
data,
|
||||
title,
|
||||
countLabel,
|
||||
earningsLabel,
|
||||
comparisonLabel,
|
||||
}: PeriodComparisonProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const resolvedTitle = title ?? t('referral.partner.stats.periodComparison');
|
||||
const resolvedCountLabel = countLabel ?? t('referral.partner.stats.referralsCount');
|
||||
const resolvedEarningsLabel = earningsLabel ?? t('referral.partner.stats.earningsAmount');
|
||||
const resolvedComparisonLabel = comparisonLabel ?? t('referral.partner.stats.vsLastWeek');
|
||||
|
||||
return (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">{resolvedTitle}</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Count comparison */}
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="text-xs text-dark-500">{resolvedCountLabel}</div>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-base font-semibold text-dark-100 sm:text-lg">
|
||||
{data.current.referrals_count}
|
||||
</span>
|
||||
<TrendBadge
|
||||
trend={data.referrals_change.trend}
|
||||
percent={data.referrals_change.percent}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-500">{resolvedComparisonLabel}</div>
|
||||
</div>
|
||||
|
||||
{/* Earnings comparison */}
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="text-xs text-dark-500">{resolvedEarningsLabel}</div>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-base font-semibold text-success-400 sm:text-lg">
|
||||
{formatWithCurrency(data.current.earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
</span>
|
||||
<TrendBadge trend={data.earnings_change.trend} percent={data.earnings_change.percent} />
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-500">{resolvedComparisonLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
src/components/stats/StatCard.tsx
Normal file
29
src/components/stats/StatCard.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
const DEFAULT_VALUE_CLASS = 'text-dark-100';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon?: ReactNode;
|
||||
valueClassName?: string;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
valueClassName = DEFAULT_VALUE_CLASS,
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||
<div className="flex items-center gap-1.5 text-sm text-dark-500">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className={`mt-1 truncate text-base font-semibold sm:text-lg ${valueClassName}`}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/components/stats/constants.ts
Normal file
5
src/components/stats/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const TREND_STYLES = {
|
||||
up: { arrow: '\u2191', className: 'text-success-400' },
|
||||
down: { arrow: '\u2193', className: 'text-error-400' },
|
||||
stable: { arrow: '\u2192', className: 'text-dark-400' },
|
||||
} as const;
|
||||
9
src/components/stats/index.ts
Normal file
9
src/components/stats/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { DailyChart } from './DailyChart';
|
||||
export { PeriodComparison } from './PeriodComparison';
|
||||
export { StatCard } from './StatCard';
|
||||
export type {
|
||||
DailyStatItem,
|
||||
PeriodStats,
|
||||
PeriodChange,
|
||||
PeriodComparison as PeriodComparisonType,
|
||||
} from './types';
|
||||
24
src/components/stats/types.ts
Normal file
24
src/components/stats/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface DailyStatItem {
|
||||
date: string;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface PeriodStats {
|
||||
days: number;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface PeriodChange {
|
||||
absolute: number;
|
||||
percent: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
}
|
||||
|
||||
export interface PeriodComparison {
|
||||
current: PeriodStats;
|
||||
previous: PeriodStats;
|
||||
referrals_change: PeriodChange;
|
||||
earnings_change: PeriodChange;
|
||||
}
|
||||
93
src/components/subscription/PurchaseCTAButton.tsx
Normal file
93
src/components/subscription/PurchaseCTAButton.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Link } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HoverBorderGradient } from '../ui/hover-border-gradient';
|
||||
import type { Subscription } from '../../types';
|
||||
|
||||
interface PurchaseCTAButtonProps {
|
||||
subscription: Subscription | null;
|
||||
}
|
||||
|
||||
export default function PurchaseCTAButton({ subscription }: PurchaseCTAButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isExpired = !subscription || (!subscription.is_active && !subscription.is_trial);
|
||||
const isTrial = subscription?.is_trial;
|
||||
|
||||
const accentColor = isExpired ? '#FF3B5C' : '#3EDBB0';
|
||||
|
||||
const buttonText = isExpired
|
||||
? t('subscription.getSubscription')
|
||||
: isTrial
|
||||
? t('subscription.trialUpgrade.title')
|
||||
: t('subscription.extend');
|
||||
|
||||
const hintText = isExpired
|
||||
? t('subscription.cta.expiredHint')
|
||||
: isTrial
|
||||
? t('subscription.cta.trialHint')
|
||||
: t('subscription.cta.activeHint');
|
||||
|
||||
return (
|
||||
<Link to="/subscription/purchase" className="block">
|
||||
<HoverBorderGradient
|
||||
accentColor={accentColor}
|
||||
duration={4}
|
||||
className="group relative w-full cursor-pointer overflow-hidden rounded-2xl"
|
||||
>
|
||||
<div
|
||||
className="relative flex items-center justify-between rounded-[14px] px-5 py-4 transition-colors duration-300"
|
||||
style={{
|
||||
background: isExpired
|
||||
? 'linear-gradient(135deg, rgba(255,59,92,0.08), rgba(255,107,53,0.06))'
|
||||
: 'linear-gradient(135deg, rgba(62,219,176,0.08), rgba(0,229,160,0.06))',
|
||||
}}
|
||||
>
|
||||
{/* Left: icon + text */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Sparkle icon */}
|
||||
<div
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl"
|
||||
style={{
|
||||
background: isExpired ? 'rgba(255,59,92,0.12)' : 'rgba(62,219,176,0.12)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={accentColor}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[15px] font-semibold text-dark-50">{buttonText}</div>
|
||||
<div className="text-[12px] text-dark-50/40">{hintText}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: chevron */}
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className="flex-shrink-0 text-dark-50/30 transition-transform duration-300 group-hover:translate-x-1"
|
||||
>
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</div>
|
||||
</HoverBorderGradient>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,8 @@ const baseClasses = `
|
||||
p-[var(--bento-padding)]
|
||||
bg-dark-900/70
|
||||
border border-dark-700/40
|
||||
transition-all duration-300 ease-smooth
|
||||
duration-300 ease-smooth
|
||||
transition-[border-color,background-color,box-shadow,transform]
|
||||
`;
|
||||
|
||||
const hoverClasses = `
|
||||
|
||||
@@ -1,24 +1,31 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { safeBoolean } from './types';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
export default function AuroraBackground({ settings }: Props) {
|
||||
const showRadialGradient = (settings.showRadialGradient as boolean) ?? true;
|
||||
const showRadialGradient = safeBoolean(settings.showRadialGradient, true);
|
||||
const paused = useAnimationPause();
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute -inset-[10px] animate-aurora opacity-50 blur-[10px] will-change-transform',
|
||||
'pointer-events-none absolute -inset-[10px] opacity-50',
|
||||
!isMobile && 'animate-aurora',
|
||||
showRadialGradient &&
|
||||
'[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,transparent_70%)]',
|
||||
)}
|
||||
style={{
|
||||
backgroundImage:
|
||||
'repeating-linear-gradient(100deg, #000 0%, #000 7%, transparent 10%, transparent 12%, #000 16%), repeating-linear-gradient(100deg, #3b82f6 10%, #a5b4fc 15%, #93c5fd 20%, #ddd6fe 25%, #60a5fa 30%)',
|
||||
backgroundSize: '300%, 200%',
|
||||
backgroundSize: isMobile ? '100%, 100%' : '300%, 200%',
|
||||
animationPlayState: paused ? 'paused' : 'running',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
@@ -71,9 +72,13 @@ function CollisionMechanism({
|
||||
const [beamKey, setBeamKey] = useState(0);
|
||||
const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkCollision = () => {
|
||||
if (beamRef.current && containerRef.current && parentRef.current && !cycleCollisionDetected) {
|
||||
const checkRef = useRef({ cycleDetected: false });
|
||||
checkRef.current.cycleDetected = cycleCollisionDetected;
|
||||
|
||||
const checkCollision = useCallback(() => {
|
||||
if (checkRef.current.cycleDetected) return;
|
||||
if (!beamRef.current || !containerRef.current || !parentRef.current) return;
|
||||
|
||||
const beamRect = beamRef.current.getBoundingClientRect();
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const parentRect = parentRef.current.getBoundingClientRect();
|
||||
@@ -85,24 +90,42 @@ function CollisionMechanism({
|
||||
setCollision({ detected: true, coordinates: { x: relativeX, y: relativeY } });
|
||||
setCycleCollisionDetected(true);
|
||||
}
|
||||
}, [containerRef, parentRef]);
|
||||
|
||||
// Throttled collision detection loop.
|
||||
// Parent unmounts this component when paused, so no visibility handling needed here.
|
||||
useEffect(() => {
|
||||
let animId = 0;
|
||||
let lastCheck = 0;
|
||||
const CHECK_INTERVAL = 100;
|
||||
|
||||
const loop = (time: number) => {
|
||||
if (time - lastCheck >= CHECK_INTERVAL) {
|
||||
lastCheck = time;
|
||||
checkCollision();
|
||||
}
|
||||
animId = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
const interval = setInterval(checkCollision, 50);
|
||||
return () => clearInterval(interval);
|
||||
}, [cycleCollisionDetected, containerRef, parentRef]);
|
||||
animId = requestAnimationFrame(loop);
|
||||
|
||||
return () => {
|
||||
if (animId) cancelAnimationFrame(animId);
|
||||
};
|
||||
}, [checkCollision]);
|
||||
|
||||
// Collision reset with proper timeout cleanup
|
||||
useEffect(() => {
|
||||
if (collision.detected && collision.coordinates) {
|
||||
setTimeout(() => {
|
||||
if (!collision.detected || !collision.coordinates) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setCollision({ detected: false, coordinates: null });
|
||||
setCycleCollisionDetected(false);
|
||||
}, 2000);
|
||||
setTimeout(() => {
|
||||
setBeamKey((prev) => prev + 1);
|
||||
}, 2000);
|
||||
}
|
||||
}, [collision]);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [collision.detected]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -154,10 +177,12 @@ function CollisionMechanism({
|
||||
export default function BackgroundBeamsCollision({ settings: _settings }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const paused = useAnimationPause();
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="absolute inset-0 overflow-hidden">
|
||||
{BEAMS.map((beam) => (
|
||||
{!paused &&
|
||||
BEAMS.map((beam) => (
|
||||
<CollisionMechanism
|
||||
key={`${beam.initialX}-beam`}
|
||||
beamOptions={beam}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// All 50 paths from the original Aceternity component
|
||||
const paths = [
|
||||
'M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875',
|
||||
'M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867',
|
||||
@@ -26,15 +27,88 @@ const paths = [
|
||||
'M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739',
|
||||
'M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731',
|
||||
'M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723',
|
||||
'M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715',
|
||||
'M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707',
|
||||
'M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699',
|
||||
'M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691',
|
||||
'M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683',
|
||||
'M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675',
|
||||
'M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667',
|
||||
'M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659',
|
||||
'M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651',
|
||||
'M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643',
|
||||
'M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635',
|
||||
'M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627',
|
||||
'M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619',
|
||||
'M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611',
|
||||
'M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603',
|
||||
'M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595',
|
||||
'M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587',
|
||||
'M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579',
|
||||
'M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571',
|
||||
'M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563',
|
||||
'M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555',
|
||||
'M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547',
|
||||
'M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539',
|
||||
'M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531',
|
||||
'M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523',
|
||||
'M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515',
|
||||
'M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507',
|
||||
'M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499',
|
||||
'M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491',
|
||||
'M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483',
|
||||
];
|
||||
|
||||
// Static background paths (subset for the radial gradient overlay)
|
||||
const bgPath =
|
||||
'M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803';
|
||||
// Concatenated static background path
|
||||
const bgPath = paths.join('');
|
||||
|
||||
// Seeded pseudo-random for deterministic values (avoids hydration mismatches)
|
||||
function seededRandom(seed: number) {
|
||||
const x = Math.sin(seed * 9301 + 49297) * 49297;
|
||||
return x - Math.floor(x);
|
||||
}
|
||||
|
||||
// Animate only every 3rd path (17 beams) — enough visual density, 3x fewer repaints
|
||||
const animatedPaths = paths
|
||||
.map((path, i) => ({ path, paramIndex: i }))
|
||||
.filter((_, i) => i % 3 === 0);
|
||||
|
||||
// Pre-compute beam animation params at module level (stable across renders)
|
||||
const beamParams = paths.map((_, i) => ({
|
||||
duration: 10 + seededRandom(i) * 10, // 10-20s
|
||||
delay: seededRandom(i + 100) * 10, // 0-10s
|
||||
}));
|
||||
|
||||
// CSS keyframes injected once
|
||||
const STYLE_ID = 'bg-beams-style';
|
||||
function ensureStyles() {
|
||||
if (typeof document === 'undefined') return;
|
||||
if (document.getElementById(STYLE_ID)) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = STYLE_ID;
|
||||
// Only animate stroke-dashoffset — no opacity changes = no repaints
|
||||
// The beam appears when dashoffset brings the segment into view
|
||||
// and disappears when it exits — pure compositor work
|
||||
style.textContent = `
|
||||
@keyframes beamDash {
|
||||
0% { stroke-dashoffset: 1; }
|
||||
100% { stroke-dashoffset: -1; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
export default React.memo(function BackgroundBeams({ settings: _settings }: Props) {
|
||||
const paused = useAnimationPause();
|
||||
|
||||
// Inject CSS once on first render
|
||||
React.useEffect(() => {
|
||||
ensureStyles();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute inset-0 overflow-hidden" style={{ contain: 'strict' }}>
|
||||
<svg
|
||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||
width="100%"
|
||||
@@ -43,55 +117,44 @@ export default React.memo(function BackgroundBeams({ settings: _settings }: Prop
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
{/* Static background paths */}
|
||||
<path d={bgPath} stroke="url(#paint0_radial)" strokeOpacity="0.05" strokeWidth="0.5" />
|
||||
|
||||
{/* Animated beam paths with moving gradients */}
|
||||
{paths.map((path, index) => (
|
||||
<motion.path
|
||||
key={`path-${index}`}
|
||||
d={path}
|
||||
stroke={`url(#linearGradient-${index})`}
|
||||
strokeOpacity="0.4"
|
||||
{/* Static background — all paths at very low opacity */}
|
||||
<path
|
||||
d={bgPath}
|
||||
stroke="url(#paint0_radial_beams)"
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
|
||||
{/* Animated beams — only every 3rd path (17 beams), pure stroke-dashoffset */}
|
||||
{animatedPaths.map(({ path, paramIndex }) => (
|
||||
<path
|
||||
key={paramIndex}
|
||||
d={path}
|
||||
stroke="url(#beamGradient)"
|
||||
strokeWidth="0.5"
|
||||
strokeOpacity="0.4"
|
||||
pathLength={1}
|
||||
strokeDasharray="0.12 0.88"
|
||||
strokeDashoffset="1"
|
||||
style={{
|
||||
animation: `beamDash ${beamParams[paramIndex].duration}s ease-in-out ${beamParams[paramIndex].delay}s infinite`,
|
||||
animationPlayState: paused ? 'paused' : 'running',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
<defs>
|
||||
{/* Animated linear gradients that move along the paths */}
|
||||
{paths.map((_, index) => (
|
||||
<motion.linearGradient
|
||||
id={`linearGradient-${index}`}
|
||||
key={`gradient-${index}`}
|
||||
initial={{
|
||||
x1: '0%',
|
||||
x2: '0%',
|
||||
y1: '0%',
|
||||
y2: '0%',
|
||||
}}
|
||||
animate={{
|
||||
x1: ['0%', '100%'],
|
||||
x2: ['0%', '95%'],
|
||||
y1: ['0%', '100%'],
|
||||
y2: ['0%', `${93 + Math.random() * 8}%`],
|
||||
}}
|
||||
transition={{
|
||||
duration: Math.random() * 10 + 10,
|
||||
ease: 'easeInOut',
|
||||
repeat: Infinity,
|
||||
delay: Math.random() * 10,
|
||||
}}
|
||||
>
|
||||
{/* Single shared gradient for all beams (cyan → purple → magenta) */}
|
||||
<linearGradient id="beamGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop stopColor="#18CCFC" stopOpacity="0" />
|
||||
<stop stopColor="#18CCFC" />
|
||||
<stop offset="32.5%" stopColor="#6344F5" />
|
||||
<stop offset="10%" stopColor="#18CCFC" />
|
||||
<stop offset="50%" stopColor="#6344F5" />
|
||||
<stop offset="100%" stopColor="#AE48FF" stopOpacity="0" />
|
||||
</motion.linearGradient>
|
||||
))}
|
||||
</linearGradient>
|
||||
|
||||
{/* Radial gradient for static background */}
|
||||
<radialGradient
|
||||
id="paint0_radial"
|
||||
id="paint0_radial_beams"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import React, { useEffect, useRef, useMemo } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
@@ -17,56 +17,143 @@ const COLORS = [
|
||||
'#c4b5fd',
|
||||
];
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const v = parseInt(hex.slice(1), 16);
|
||||
return [(v >> 16) & 255, (v >> 8) & 255, v & 255];
|
||||
}
|
||||
|
||||
interface CellData {
|
||||
rgb: [number, number, number];
|
||||
phase: number;
|
||||
period: number;
|
||||
}
|
||||
|
||||
const SKEW_X_TAN = Math.tan((-48 * Math.PI) / 180);
|
||||
const SKEW_Y_TAN = Math.tan((14 * Math.PI) / 180);
|
||||
const GRID_SCALE = 0.675;
|
||||
|
||||
interface BoxesState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export default React.memo(function BackgroundBoxes({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stateRef = useRef<BoxesState | null>(null);
|
||||
const rows = clampNumber(settings.rows, 4, 30, 15);
|
||||
const cols = clampNumber(settings.cols, 4, 30, 15);
|
||||
const boxColor = sanitizeColor(settings.boxColor, '#818cf8');
|
||||
|
||||
const cells = useMemo(() => {
|
||||
const result: { color: string; delay: number; duration: number }[] = [];
|
||||
for (let i = 0; i < rows * cols; i++) {
|
||||
result.push({
|
||||
color:
|
||||
const cells = useMemo((): CellData[] => {
|
||||
return Array.from({ length: rows * cols }, () => ({
|
||||
rgb: hexToRgb(
|
||||
boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor,
|
||||
delay: Math.random() * 8,
|
||||
duration: 3 + Math.random() * 4,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
),
|
||||
phase: Math.random() * 8,
|
||||
period: 3 + Math.random() * 4,
|
||||
}));
|
||||
}, [rows, cols, boxColor]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const dpr = getMobileDpr();
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent) return;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
stateRef.current = { ctx, w: canvas.width, h: canvas.height };
|
||||
|
||||
const onResize = () => {
|
||||
const r = parent.getBoundingClientRect();
|
||||
canvas.width = r.width * dpr;
|
||||
canvas.height = r.height * dpr;
|
||||
if (stateRef.current) {
|
||||
stateRef.current.w = canvas.width;
|
||||
stateRef.current.h = canvas.height;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
useAnimationLoop(
|
||||
(now) => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const { ctx, w, h } = state;
|
||||
const t = now * 0.001;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.save();
|
||||
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
ctx.translate(cx, cy);
|
||||
ctx.transform(1, 0, SKEW_X_TAN, 1, 0, 0);
|
||||
ctx.transform(1, SKEW_Y_TAN, 0, 1, 0, 0);
|
||||
ctx.scale(GRID_SCALE, GRID_SCALE);
|
||||
ctx.translate(-cx, -cy);
|
||||
|
||||
const gw = w * 3;
|
||||
const gh = h * 3;
|
||||
const ox = -w;
|
||||
const oy = -h;
|
||||
const cellW = gw / cols;
|
||||
const cellH = gh / rows;
|
||||
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const cell = cells[i];
|
||||
const cycleT = ((t + cell.phase) % cell.period) / cell.period;
|
||||
const alpha = 0.15 * Math.sin(cycleT * Math.PI);
|
||||
if (alpha < 0.005) continue;
|
||||
|
||||
const col = i % cols;
|
||||
const row = (i - col) / cols;
|
||||
const [r, g, b] = cell.rgb;
|
||||
|
||||
ctx.fillStyle = `rgba(${r},${g},${b},${alpha})`;
|
||||
ctx.fillRect(ox + col * cellW, oy + row * cellH, cellW, cellH);
|
||||
}
|
||||
|
||||
ctx.strokeStyle = 'rgba(51,65,85,0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
|
||||
for (let r = 0; r <= rows; r++) {
|
||||
const y = oy + r * cellH;
|
||||
ctx.moveTo(ox, y);
|
||||
ctx.lineTo(ox + gw, y);
|
||||
}
|
||||
for (let c = 0; c <= cols; c++) {
|
||||
const x = ox + c * cellW;
|
||||
ctx.moveTo(x, oy);
|
||||
ctx.lineTo(x, oy + gh);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
[cells, rows, cols],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-100%',
|
||||
left: '-100%',
|
||||
width: '300%',
|
||||
height: '300%',
|
||||
display: 'grid',
|
||||
gridTemplateRows: `repeat(${rows}, 1fr)`,
|
||||
gridTemplateColumns: `repeat(${cols}, 1fr)`,
|
||||
transform: 'skewX(-48deg) skewY(14deg) scale(0.675) translateZ(0)',
|
||||
transformOrigin: 'center center',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{cells.map((cell, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="border border-slate-700/50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: [0, 0.15, 0] }}
|
||||
transition={{
|
||||
duration: cell.duration,
|
||||
delay: cell.delay,
|
||||
repeat: Infinity,
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
style={{ backgroundColor: cell.color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sanitizeColor } from './types';
|
||||
import { sanitizeColor, safeBoolean, safeSelect } from './types';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
function hexToRgbString(hex: string): string {
|
||||
hex = hex.replace('#', '');
|
||||
if (hex.length === 3)
|
||||
@@ -22,23 +25,29 @@ function hexToRgbString(hex: string): string {
|
||||
|
||||
export default function BackgroundGradientAnimation({ settings }: Props) {
|
||||
const interactiveRef = useRef<HTMLDivElement>(null);
|
||||
const paused = useAnimationPause();
|
||||
|
||||
const firstColor = hexToRgbString(sanitizeColor(settings.firstColor, '#1271FF'));
|
||||
const secondColor = hexToRgbString(sanitizeColor(settings.secondColor, '#DD4AFF'));
|
||||
const thirdColor = hexToRgbString(sanitizeColor(settings.thirdColor, '#64DCFF'));
|
||||
const fourthColor = hexToRgbString(sanitizeColor(settings.fourthColor, '#C83232'));
|
||||
const fifthColor = hexToRgbString(sanitizeColor(settings.fifthColor, '#B4B432'));
|
||||
const interactive = (settings.interactive as boolean) ?? true;
|
||||
const size = (settings.size as string) ?? '80%';
|
||||
const interactive = safeBoolean(settings.interactive, true);
|
||||
const size = safeSelect(settings.size, ['60%', '80%', '100%'] as const, '80%');
|
||||
|
||||
useEffect(() => {
|
||||
if (!interactive || !interactiveRef.current) return;
|
||||
// Interactive mouse tracking — DESKTOP ONLY
|
||||
if (!interactive || !interactiveRef.current || isMobile) return;
|
||||
|
||||
let curX = 0;
|
||||
let curY = 0;
|
||||
let tgX = 0;
|
||||
let tgY = 0;
|
||||
let animId = 0;
|
||||
let docHidden = document.hidden;
|
||||
let tgInactive = false;
|
||||
|
||||
const isHidden = () => docHidden || tgInactive;
|
||||
|
||||
const move = () => {
|
||||
curX += (tgX - curX) / 20;
|
||||
@@ -49,20 +58,62 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
||||
animId = requestAnimationFrame(move);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (animId) return;
|
||||
animId = requestAnimationFrame(move);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (animId) {
|
||||
cancelAnimationFrame(animId);
|
||||
animId = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouse = (e: MouseEvent) => {
|
||||
tgX = e.clientX;
|
||||
tgY = e.clientY;
|
||||
};
|
||||
|
||||
const onVisibility = () => {
|
||||
docHidden = document.hidden;
|
||||
if (isHidden()) stop();
|
||||
else start();
|
||||
};
|
||||
|
||||
const tg = window.Telegram?.WebApp;
|
||||
const onActivated = () => {
|
||||
tgInactive = false;
|
||||
if (!isHidden()) start();
|
||||
};
|
||||
const onDeactivated = () => {
|
||||
tgInactive = true;
|
||||
stop();
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouse);
|
||||
animId = requestAnimationFrame(move);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
if (tg) {
|
||||
tg.onEvent?.('activated', onActivated);
|
||||
tg.onEvent?.('deactivated', onDeactivated);
|
||||
}
|
||||
if (!isHidden()) start();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
window.removeEventListener('mousemove', handleMouse);
|
||||
cancelAnimationFrame(animId);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
if (tg) {
|
||||
tg.offEvent?.('activated', onActivated);
|
||||
tg.offEvent?.('deactivated', onDeactivated);
|
||||
}
|
||||
};
|
||||
}, [interactive]);
|
||||
|
||||
// Mobile: use simpler blur (just CSS blur, no SVG filter)
|
||||
// Desktop: keep the goo SVG filter + CSS blur for full effect
|
||||
const filterStyle = isMobile ? 'blur(20px)' : 'url(#blurMe) blur(40px)';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
@@ -74,11 +125,12 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
||||
'--fourth-color': fourthColor,
|
||||
'--fifth-color': fifthColor,
|
||||
'--size': size,
|
||||
'--blending': 'hard-light',
|
||||
background: `linear-gradient(40deg, rgb(${firstColor}), rgb(${fifthColor}))`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* SVG filter only rendered on desktop */}
|
||||
{!isMobile && (
|
||||
<svg className="hidden">
|
||||
<defs>
|
||||
<filter id="blurMe">
|
||||
@@ -93,7 +145,8 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
<div className="absolute inset-0" style={{ filter: 'url(#blurMe) blur(40px)' }}>
|
||||
)}
|
||||
<div className="absolute inset-0" style={{ filter: filterStyle }}>
|
||||
{[
|
||||
{ color: firstColor, anim: 'animate-move-vertical' },
|
||||
{ color: secondColor, anim: 'animate-move-in-circle' },
|
||||
@@ -104,15 +157,17 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'absolute left-[calc(50%-var(--size)/2)] top-[calc(50%-var(--size)/2)] h-[var(--size)] w-[var(--size)] rounded-full opacity-100 mix-blend-hard-light',
|
||||
'absolute left-[calc(50%-var(--size)/2)] top-[calc(50%-var(--size)/2)] h-[var(--size)] w-[var(--size)] rounded-full',
|
||||
isMobile ? 'opacity-50' : 'opacity-100 mix-blend-hard-light',
|
||||
blob.anim,
|
||||
)}
|
||||
style={{
|
||||
background: `radial-gradient(circle at center, rgba(${blob.color}, 0.8) 0, rgba(${blob.color}, 0) 50%) no-repeat`,
|
||||
animationPlayState: paused ? 'paused' : 'running',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{interactive && (
|
||||
{interactive && !isMobile && (
|
||||
<div
|
||||
ref={interactiveRef}
|
||||
className="absolute left-[calc(50%-var(--size)/2)] top-[calc(50%-var(--size)/2)] h-[var(--size)] w-[var(--size)] rounded-full opacity-70 mix-blend-hard-light"
|
||||
|
||||
@@ -1,50 +1,93 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createNoise3D } from 'simplex-noise';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
interface LinesState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
noise3D: ReturnType<typeof createNoise3D>;
|
||||
nt: number;
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
export default function BackgroundLines({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>(0);
|
||||
const stateRef = useRef<LinesState | null>(null);
|
||||
|
||||
const lineCount = clampNumber(settings.lineCount, 5, 100, 40);
|
||||
const lineColor = sanitizeColor(settings.lineColor, '#818cf8');
|
||||
const speed = clampNumber(settings.speed, 0.0005, 0.01, 0.002);
|
||||
const strokeWidth = clampNumber(settings.strokeWidth, 0.5, 5, 1);
|
||||
|
||||
const effectiveLineCount = isMobile ? Math.min(lineCount, 20) : lineCount;
|
||||
const step = isMobile ? 8 : 4;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const dpr = getMobileDpr();
|
||||
const parent = canvas.parentElement;
|
||||
const w = parent?.offsetWidth ?? window.innerWidth;
|
||||
const h = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const noise3D = createNoise3D();
|
||||
let nt = 0;
|
||||
stateRef.current = { ctx, noise3D: createNoise3D(), nt: 0, w, h, dpr };
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||
const onResize = () => {
|
||||
const nw = parent?.offsetWidth ?? window.innerWidth;
|
||||
const nh = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = nw * dpr;
|
||||
canvas.height = nh * dpr;
|
||||
canvas.style.width = `${nw}px`;
|
||||
canvas.style.height = `${nh}px`;
|
||||
if (stateRef.current) {
|
||||
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
stateRef.current.w = nw;
|
||||
stateRef.current.h = nh;
|
||||
}
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
nt += speed;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const yBase = (i / lineCount) * canvas.height;
|
||||
const opacity = 0.1 + 0.15 * Math.sin((i / lineCount) * Math.PI);
|
||||
useAnimationLoop(() => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const { ctx, noise3D, w, h } = state;
|
||||
state.nt += speed;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = lineColor;
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.lineWidth = strokeWidth;
|
||||
|
||||
for (let x = 0; x < canvas.width; x += 4) {
|
||||
const y = yBase + noise3D(x / 600, i * 0.3, nt) * 40;
|
||||
for (let i = 0; i < effectiveLineCount; i++) {
|
||||
const yBase = (i / effectiveLineCount) * h;
|
||||
const opacity = 0.1 + 0.15 * Math.sin((i / effectiveLineCount) * Math.PI);
|
||||
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.beginPath();
|
||||
|
||||
for (let x = 0; x < w; x += step) {
|
||||
const y = yBase + noise3D(x / 600, i * 0.3, state.nt) * 40;
|
||||
if (x === 0) {
|
||||
ctx.moveTo(x, y);
|
||||
} else {
|
||||
@@ -56,18 +99,7 @@ export default function BackgroundLines({ settings }: Props) {
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
resize();
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [lineCount, lineColor, speed, strokeWidth]);
|
||||
}, [effectiveLineCount, lineColor, speed, strokeWidth]);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface RippleState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
ripples: { phase: number }[];
|
||||
}
|
||||
|
||||
export default function BackgroundRipple({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>(0);
|
||||
const stateRef = useRef<RippleState | null>(null);
|
||||
|
||||
const rippleColor = sanitizeColor(settings.rippleColor, '#818cf8');
|
||||
const rippleCount = clampNumber(settings.rippleCount, 1, 20, 5);
|
||||
@@ -17,24 +26,53 @@ export default function BackgroundRipple({ settings }: Props) {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const dpr = getMobileDpr();
|
||||
const parent = canvas.parentElement;
|
||||
const w = parent?.offsetWidth ?? window.innerWidth;
|
||||
const h = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||
};
|
||||
|
||||
const ripples = Array.from({ length: rippleCount }, (_, i) => ({
|
||||
phase: (i / rippleCount) * Math.PI * 2,
|
||||
maxRadius: 0,
|
||||
}));
|
||||
|
||||
const animate = (time: number) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
stateRef.current = { ctx, w, h, dpr, ripples };
|
||||
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const onResize = () => {
|
||||
const nw = parent?.offsetWidth ?? window.innerWidth;
|
||||
const nh = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = nw * dpr;
|
||||
canvas.height = nh * dpr;
|
||||
canvas.style.width = `${nw}px`;
|
||||
canvas.style.height = `${nh}px`;
|
||||
if (stateRef.current) {
|
||||
stateRef.current.w = nw;
|
||||
stateRef.current.h = nh;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [rippleCount]);
|
||||
|
||||
useAnimationLoop(
|
||||
(time) => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const { ctx, w, h, dpr, ripples } = state;
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const maxR = Math.sqrt(cx * cx + cy * cy);
|
||||
|
||||
for (let i = 0; i < ripples.length; i++) {
|
||||
@@ -52,18 +90,9 @@ export default function BackgroundRipple({ settings }: Props) {
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
resize();
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [rippleColor, rippleCount, speed]);
|
||||
},
|
||||
[rippleColor, rippleCount, speed],
|
||||
);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { sanitizeColor, clampNumber, safeSelect } from './types';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export default function GridBackground({ settings }: Props) {
|
||||
const variant = (settings.variant as string) ?? 'grid';
|
||||
const variant = safeSelect(settings.variant, ['grid', 'dots'] as const, 'grid');
|
||||
const gridColor = sanitizeColor(settings.gridColor, 'rgba(255,255,255,0.05)');
|
||||
const gridSize = clampNumber(settings.gridSize, 10, 200, 40);
|
||||
const dotSize = clampNumber(settings.dotSize, 0.5, 10, 1.5);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
@@ -8,6 +9,7 @@ interface Props {
|
||||
export default function Meteors({ settings }: Props) {
|
||||
const count = clampNumber(settings.count, 1, 50, 20);
|
||||
const meteorColor = sanitizeColor(settings.meteorColor, '#ffffff');
|
||||
const paused = useAnimationPause();
|
||||
|
||||
const meteors = useMemo(
|
||||
() =>
|
||||
@@ -31,6 +33,7 @@ export default function Meteors({ settings }: Props) {
|
||||
left: meteor.left,
|
||||
animationDelay: meteor.delay,
|
||||
animationDuration: meteor.duration,
|
||||
animationPlayState: paused ? 'paused' : 'running',
|
||||
width: meteor.size,
|
||||
height: meteor.size,
|
||||
boxShadow: `0 0 0 1px rgba(255,255,255,0.05), 0 0 2px 1px ${meteorColor}20, 0 0 20px 2px ${meteorColor}40`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
@@ -23,9 +24,20 @@ interface BgStar {
|
||||
twinkleSpeed: number | null;
|
||||
}
|
||||
|
||||
interface ShootingState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
shootingStars: Star[];
|
||||
bgStars: BgStar[];
|
||||
lastShootingTime: number;
|
||||
nextShootingDelay: number;
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
export default function ShootingStarsBackground({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>(0);
|
||||
const stateRef = useRef<ShootingState | null>(null);
|
||||
|
||||
const starColor = sanitizeColor(settings.starColor, '#9E00FF');
|
||||
const trailColor = sanitizeColor(settings.trailColor, '#2EB9DF');
|
||||
@@ -37,46 +49,67 @@ export default function ShootingStarsBackground({ settings }: Props) {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const dpr = getMobileDpr();
|
||||
const parent = canvas.parentElement;
|
||||
const w = parent?.offsetWidth ?? window.innerWidth;
|
||||
const h = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let shootingStars: Star[] = [];
|
||||
let bgStars: BgStar[] = [];
|
||||
let lastShootingTime = 0;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||
initBgStars();
|
||||
};
|
||||
|
||||
const initBgStars = () => {
|
||||
const count = Math.floor(canvas.width * canvas.height * starDensity);
|
||||
bgStars = Array.from({ length: count }, () => ({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
const count = Math.floor(w * h * starDensity);
|
||||
const bgStars: BgStar[] = Array.from({ length: count }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
radius: Math.random() * 1.2 + 0.3,
|
||||
opacity: Math.random(),
|
||||
twinkleSpeed: Math.random() > 0.3 ? 0.5 + Math.random() * 0.5 : null,
|
||||
}));
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
stateRef.current = {
|
||||
ctx,
|
||||
shootingStars: [],
|
||||
bgStars,
|
||||
lastShootingTime: 0,
|
||||
nextShootingDelay: 4200 + Math.random() * 4500,
|
||||
w,
|
||||
h,
|
||||
dpr,
|
||||
};
|
||||
|
||||
const spawnShootingStar = () => {
|
||||
shootingStars.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height * 0.5,
|
||||
angle: Math.PI / 4 + (Math.random() - 0.5) * 0.3,
|
||||
scale: 0.5 + Math.random() * 0.5,
|
||||
speed: minSpeed + Math.random() * (maxSpeed - minSpeed),
|
||||
distance: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
const onResize = () => {
|
||||
const nw = parent?.offsetWidth ?? window.innerWidth;
|
||||
const nh = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = nw * dpr;
|
||||
canvas.height = nh * dpr;
|
||||
canvas.style.width = `${nw}px`;
|
||||
canvas.style.height = `${nh}px`;
|
||||
if (stateRef.current) {
|
||||
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
stateRef.current.w = nw;
|
||||
stateRef.current.h = nh;
|
||||
}
|
||||
};
|
||||
|
||||
const animate = (time: number) => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [starDensity]);
|
||||
|
||||
useAnimationLoop(
|
||||
(time) => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const { ctx, bgStars, w, h } = state;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Background stars
|
||||
for (const s of bgStars) {
|
||||
let opacity = s.opacity;
|
||||
if (s.twinkleSpeed) {
|
||||
@@ -88,14 +121,21 @@ export default function ShootingStarsBackground({ settings }: Props) {
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Spawn shooting stars
|
||||
if (time - lastShootingTime > 4200 + Math.random() * 4500) {
|
||||
spawnShootingStar();
|
||||
lastShootingTime = time;
|
||||
if (time - state.lastShootingTime > state.nextShootingDelay) {
|
||||
state.shootingStars.push({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h * 0.5,
|
||||
angle: Math.PI / 4 + (Math.random() - 0.5) * 0.3,
|
||||
scale: 0.5 + Math.random() * 0.5,
|
||||
speed: minSpeed + Math.random() * (maxSpeed - minSpeed),
|
||||
distance: 0,
|
||||
opacity: 1,
|
||||
});
|
||||
state.lastShootingTime = time;
|
||||
state.nextShootingDelay = 4200 + Math.random() * 4500;
|
||||
}
|
||||
|
||||
// Draw shooting stars
|
||||
shootingStars = shootingStars.filter((star) => {
|
||||
state.shootingStars = state.shootingStars.filter((star) => {
|
||||
star.distance += star.speed;
|
||||
star.opacity = Math.max(0, 1 - star.distance / 500);
|
||||
|
||||
@@ -106,36 +146,28 @@ export default function ShootingStarsBackground({ settings }: Props) {
|
||||
const tailX = star.x + Math.cos(star.angle) * Math.max(0, star.distance - 80);
|
||||
const tailY = star.y + Math.sin(star.angle) * Math.max(0, star.distance - 80);
|
||||
|
||||
const gradient = ctx.createLinearGradient(tailX, tailY, x2, y2);
|
||||
gradient.addColorStop(0, 'transparent');
|
||||
gradient.addColorStop(0.5, trailColor);
|
||||
gradient.addColorStop(1, starColor);
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = gradient;
|
||||
// Draw trail with flat color (avoids per-frame gradient allocation)
|
||||
ctx.lineWidth = star.scale * 2;
|
||||
ctx.globalAlpha = star.opacity;
|
||||
ctx.globalAlpha = star.opacity * 0.4;
|
||||
ctx.strokeStyle = trailColor;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tailX, tailY);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// Draw head dot
|
||||
ctx.globalAlpha = star.opacity;
|
||||
ctx.fillStyle = starColor;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x2, y2, star.scale * 1.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
resize();
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [starColor, trailColor, starDensity, minSpeed, maxSpeed]);
|
||||
},
|
||||
[starColor, trailColor, starDensity, minSpeed, maxSpeed],
|
||||
);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
@@ -16,18 +17,16 @@ interface Particle {
|
||||
opacitySpeed: number;
|
||||
}
|
||||
|
||||
export default function Sparkles({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>(0);
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
interface SparklesState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
particles: Particle[];
|
||||
rgb: { r: number; g: number; b: number };
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
const particleDensity = clampNumber(settings.particleDensity, 50, 5000, 800);
|
||||
const minSize = clampNumber(settings.minSize, 0.1, 5, 0.4);
|
||||
const maxSize = clampNumber(settings.maxSize, 0.5, 10, 1.4);
|
||||
const speed = clampNumber(settings.speed, 0.1, 10, 2);
|
||||
const particleColor = sanitizeColor(settings.particleColor, '#FFFFFF');
|
||||
|
||||
const hexToRgb = useCallback((hex: string) => {
|
||||
function hexToRgb(hex: string) {
|
||||
hex = hex.replace('#', '');
|
||||
if (hex.length === 3)
|
||||
hex = hex
|
||||
@@ -39,27 +38,40 @@ export default function Sparkles({ settings }: Props) {
|
||||
g: parseInt(hex.substring(2, 4), 16),
|
||||
b: parseInt(hex.substring(4, 6), 16),
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
export default function Sparkles({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const stateRef = useRef<SparklesState | null>(null);
|
||||
|
||||
const particleDensity = clampNumber(settings.particleDensity, 50, 5000, 800);
|
||||
const minSize = clampNumber(settings.minSize, 0.1, 5, 0.4);
|
||||
const maxSize = clampNumber(settings.maxSize, 0.5, 10, 1.4);
|
||||
const speed = clampNumber(settings.speed, 0.1, 10, 2);
|
||||
const particleColor = sanitizeColor(settings.particleColor, '#FFFFFF');
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const dpr = getMobileDpr();
|
||||
const parent = canvas.parentElement;
|
||||
const w = parent?.offsetWidth ?? window.innerWidth;
|
||||
const h = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||
initParticles();
|
||||
};
|
||||
|
||||
const initParticles = () => {
|
||||
const area = canvas.width * canvas.height;
|
||||
const area = w * h;
|
||||
const count = Math.floor((area / 1000000) * particleDensity);
|
||||
particlesRef.current = Array.from({ length: count }, () => ({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
const particles: Particle[] = Array.from({ length: count }, () => ({
|
||||
x: Math.random() * w,
|
||||
y: Math.random() * h,
|
||||
size: minSize + Math.random() * (maxSize - minSize),
|
||||
speedX: (Math.random() - 0.5) * speed * 0.2,
|
||||
speedY: (Math.random() - 0.5) * speed * 0.2,
|
||||
@@ -67,14 +79,36 @@ export default function Sparkles({ settings }: Props) {
|
||||
opacityDirection: Math.random() > 0.5 ? 1 : -1,
|
||||
opacitySpeed: 0.005 + Math.random() * 0.01 * speed,
|
||||
}));
|
||||
|
||||
stateRef.current = { ctx, particles, rgb: hexToRgb(particleColor), w, h, dpr };
|
||||
|
||||
const onResize = () => {
|
||||
const nw = parent?.offsetWidth ?? window.innerWidth;
|
||||
const nh = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = nw * dpr;
|
||||
canvas.height = nh * dpr;
|
||||
canvas.style.width = `${nw}px`;
|
||||
canvas.style.height = `${nh}px`;
|
||||
if (stateRef.current) {
|
||||
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
stateRef.current.w = nw;
|
||||
stateRef.current.h = nh;
|
||||
}
|
||||
};
|
||||
|
||||
const rgb = hexToRgb(particleColor);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [particleDensity, minSize, maxSize, speed, particleColor]);
|
||||
|
||||
const animate = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
useAnimationLoop(() => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
for (const p of particlesRef.current) {
|
||||
const { ctx, particles, rgb, w, h } = state;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
for (const p of particles) {
|
||||
p.x += p.speedX;
|
||||
p.y += p.speedY;
|
||||
p.opacity += p.opacityDirection * p.opacitySpeed;
|
||||
@@ -87,29 +121,17 @@ export default function Sparkles({ settings }: Props) {
|
||||
p.opacityDirection = -1;
|
||||
}
|
||||
|
||||
if (p.x < 0) p.x = canvas.width;
|
||||
if (p.x > canvas.width) p.x = 0;
|
||||
if (p.y < 0) p.y = canvas.height;
|
||||
if (p.y > canvas.height) p.y = 0;
|
||||
if (p.x < 0) p.x = w;
|
||||
if (p.x > w) p.x = 0;
|
||||
if (p.y < 0) p.y = h;
|
||||
if (p.y > h) p.y = 0;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${p.opacity})`;
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
resize();
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [particleDensity, minSize, maxSize, speed, particleColor, hexToRgb]);
|
||||
}, [particleDensity, minSize, maxSize, speed, particleColor]);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
export default function SpotlightBg({ settings }: Props) {
|
||||
const spotlightColor = sanitizeColor(settings.spotlightColor, '#818cf8');
|
||||
const spotlightSize = clampNumber(settings.spotlightSize, 100, 1000, 400);
|
||||
const paused = useAnimationPause();
|
||||
|
||||
const blur1 = isMobile ? 0 : 60;
|
||||
const blur2 = isMobile ? 0 : 80;
|
||||
|
||||
if (paused) {
|
||||
return <div className="absolute inset-0 overflow-hidden" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
@@ -27,7 +38,7 @@ export default function SpotlightBg({ settings }: Props) {
|
||||
height: spotlightSize,
|
||||
background: `radial-gradient(circle, ${spotlightColor}40 0%, transparent 70%)`,
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(60px)',
|
||||
filter: `blur(${blur1}px)`,
|
||||
}}
|
||||
/>
|
||||
<motion.div
|
||||
@@ -46,7 +57,7 @@ export default function SpotlightBg({ settings }: Props) {
|
||||
height: spotlightSize * 0.8,
|
||||
background: `radial-gradient(circle, ${spotlightColor}30 0%, transparent 70%)`,
|
||||
borderRadius: '50%',
|
||||
filter: 'blur(80px)',
|
||||
filter: `blur(${blur2}px)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -42,17 +42,30 @@ export function sanitizeColor(value: unknown, fallback: string): string {
|
||||
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
||||
// Allow rgb/rgba/hsl/hsla with numbers, commas, spaces, dots, %
|
||||
if (/^(rgb|hsl)a?\([0-9,.\s/%]+\)$/.test(trimmed)) return trimmed;
|
||||
// Allow CSS named colors (alphanumeric only)
|
||||
if (/^[a-zA-Z]{3,30}$/.test(trimmed)) return trimmed;
|
||||
// Allow CSS named colors (alphanumeric only, exclude CSS-wide keywords)
|
||||
if (/^[a-zA-Z]{3,30}$/.test(trimmed) && !CSS_WIDE_KEYWORDS.has(trimmed.toLowerCase()))
|
||||
return trimmed;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Clamp a numeric value within bounds */
|
||||
export function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
|
||||
const n = typeof value === 'number' ? value : fallback;
|
||||
const n = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
/** Safely extract a boolean setting with fallback */
|
||||
export function safeBoolean(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
/** Safely extract a string setting from an allowed set of values */
|
||||
export function safeSelect(value: unknown, allowed: readonly string[], fallback: string): string {
|
||||
return typeof value === 'string' && allowed.includes(value) ? value : fallback;
|
||||
}
|
||||
|
||||
const CSS_WIDE_KEYWORDS = new Set(['inherit', 'initial', 'unset', 'revert', 'revert-layer']);
|
||||
|
||||
export interface SettingDefinition {
|
||||
key: string;
|
||||
label: string;
|
||||
|
||||
@@ -1,14 +1,37 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createNoise3D } from 'simplex-noise';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
const TAU = 2 * Math.PI;
|
||||
const lerp = (n1: number, n2: number, speed: number) => (1 - speed) * n1 + speed * n2;
|
||||
const fadeInOut = (t: number, m: number) => {
|
||||
const hm = 0.5 * m;
|
||||
return Math.abs(((t + hm) % m) - hm) / hm;
|
||||
};
|
||||
const rand = (n: number) => n * Math.random();
|
||||
const randRange = (n: number) => n - rand(2 * n);
|
||||
|
||||
interface VortexState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
noise3D: ReturnType<typeof createNoise3D>;
|
||||
particleProps: Float32Array;
|
||||
tick: number;
|
||||
center: [number, number];
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
export default function VortexBackground({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>(0);
|
||||
const stateRef = useRef<VortexState | null>(null);
|
||||
|
||||
const particleCount = clampNumber(settings.particleCount, 50, 2000, 500);
|
||||
const rangeY = clampNumber(settings.rangeY, 10, 500, 100);
|
||||
@@ -16,86 +39,82 @@ export default function VortexBackground({ settings }: Props) {
|
||||
const rangeSpeed = clampNumber(settings.rangeSpeed, 0.1, 5, 1.5);
|
||||
const backgroundColor = sanitizeColor(settings.backgroundColor, '#000000');
|
||||
|
||||
const particlePropCount = 9;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const dpr = getMobileDpr();
|
||||
const parent = canvas.parentElement;
|
||||
const w = parent?.offsetWidth ?? window.innerWidth;
|
||||
const h = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.lineCap = 'round';
|
||||
|
||||
const noise3D = createNoise3D();
|
||||
const particlePropCount = 9;
|
||||
const particlePropsLength = particleCount * particlePropCount;
|
||||
let particleProps = new Float32Array(particlePropsLength);
|
||||
let tick = 0;
|
||||
let center: [number, number] = [0, 0];
|
||||
const particleProps = new Float32Array(particlePropsLength);
|
||||
const center: [number, number] = [0.5 * w, 0.5 * h];
|
||||
|
||||
const rand = (n: number) => n * Math.random();
|
||||
const randRange = (n: number) => n - rand(2 * n);
|
||||
const TAU = 2 * Math.PI;
|
||||
const fadeInOut = (t: number, m: number) => {
|
||||
const hm = 0.5 * m;
|
||||
return Math.abs(((t + hm) % m) - hm) / hm;
|
||||
};
|
||||
const lerp = (n1: number, n2: number, speed: number) => (1 - speed) * n1 + speed * n2;
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||
center = [0.5 * canvas.width, 0.5 * canvas.height];
|
||||
};
|
||||
|
||||
const initParticle = (i: number) => {
|
||||
const x = rand(canvas.width);
|
||||
const y = center[1] + randRange(rangeY);
|
||||
const life = 0;
|
||||
const ttl = 50 + rand(150);
|
||||
const speed = rand(rangeSpeed);
|
||||
const radius = 1 + rand(2);
|
||||
const hue = baseHue + rand(100);
|
||||
particleProps.set([x, y, 0, 0, life, ttl, speed, radius, hue], i);
|
||||
};
|
||||
|
||||
const initParticles = () => {
|
||||
tick = 0;
|
||||
particleProps = new Float32Array(particlePropsLength);
|
||||
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
|
||||
initParticle(i);
|
||||
particleProps[i] = rand(w);
|
||||
particleProps[i + 1] = center[1] + randRange(rangeY);
|
||||
particleProps[i + 2] = 0;
|
||||
particleProps[i + 3] = 0;
|
||||
particleProps[i + 4] = 0;
|
||||
particleProps[i + 5] = 50 + rand(150);
|
||||
particleProps[i + 6] = rand(rangeSpeed);
|
||||
particleProps[i + 7] = 1 + rand(2);
|
||||
particleProps[i + 8] = baseHue + rand(100);
|
||||
}
|
||||
|
||||
stateRef.current = { ctx, noise3D, particleProps, tick: 0, center, w, h, dpr };
|
||||
|
||||
const onResize = () => {
|
||||
const nw = parent?.offsetWidth ?? window.innerWidth;
|
||||
const nh = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = nw * dpr;
|
||||
canvas.height = nh * dpr;
|
||||
canvas.style.width = `${nw}px`;
|
||||
canvas.style.height = `${nh}px`;
|
||||
if (stateRef.current) {
|
||||
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
stateRef.current.ctx.lineCap = 'round';
|
||||
stateRef.current.center = [0.5 * nw, 0.5 * nh];
|
||||
stateRef.current.w = nw;
|
||||
stateRef.current.h = nh;
|
||||
}
|
||||
};
|
||||
|
||||
const drawParticle = (
|
||||
x: number,
|
||||
y: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
life: number,
|
||||
ttl: number,
|
||||
radius: number,
|
||||
hue: number,
|
||||
) => {
|
||||
ctx.save();
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineWidth = radius;
|
||||
ctx.strokeStyle = `hsla(${hue},100%,60%,${fadeInOut(life, ttl)})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
ctx.restore();
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [particleCount, rangeY, baseHue, rangeSpeed]);
|
||||
|
||||
const draw = () => {
|
||||
tick++;
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
useAnimationLoop(() => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const { ctx, noise3D, particleProps, w, h } = state;
|
||||
const particlePropsLength = particleCount * particlePropCount;
|
||||
|
||||
state.tick++;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
|
||||
const x = particleProps[i];
|
||||
const y = particleProps[i + 1];
|
||||
const n = noise3D(x * 0.00125, y * 0.00125, tick * 0.0005) * 3 * TAU;
|
||||
const n = noise3D(x * 0.00125, y * 0.00125, state.tick * 0.0005) * 3 * TAU;
|
||||
const vx = lerp(particleProps[i + 2], Math.cos(n), 0.5);
|
||||
const vy = lerp(particleProps[i + 3], Math.sin(n), 0.5);
|
||||
const life = particleProps[i + 4];
|
||||
@@ -106,7 +125,12 @@ export default function VortexBackground({ settings }: Props) {
|
||||
const radius = particleProps[i + 7];
|
||||
const hue = particleProps[i + 8];
|
||||
|
||||
drawParticle(x, y, x2, y2, life, ttl, radius, hue);
|
||||
ctx.lineWidth = radius;
|
||||
ctx.strokeStyle = `hsla(${hue},100%,60%,${fadeInOut(life, ttl)})`;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
|
||||
particleProps[i] = x2;
|
||||
particleProps[i + 1] = y2;
|
||||
@@ -114,30 +138,28 @@ export default function VortexBackground({ settings }: Props) {
|
||||
particleProps[i + 3] = vy;
|
||||
particleProps[i + 4] = life + 1;
|
||||
|
||||
if (x2 > canvas.width || x2 < 0 || y2 > canvas.height || y2 < 0 || life + 1 > ttl) {
|
||||
initParticle(i);
|
||||
if (x2 > w || x2 < 0 || y2 > h || y2 < 0 || life + 1 > ttl) {
|
||||
particleProps[i] = rand(w);
|
||||
particleProps[i + 1] = state.center[1] + randRange(rangeY);
|
||||
particleProps[i + 2] = 0;
|
||||
particleProps[i + 3] = 0;
|
||||
particleProps[i + 4] = 0;
|
||||
particleProps[i + 5] = 50 + rand(150);
|
||||
particleProps[i + 6] = rand(rangeSpeed);
|
||||
particleProps[i + 7] = 1 + rand(2);
|
||||
particleProps[i + 8] = baseHue + rand(100);
|
||||
}
|
||||
}
|
||||
|
||||
// Glow effect
|
||||
// Glow effect — DESKTOP ONLY
|
||||
if (!isMobile) {
|
||||
ctx.save();
|
||||
ctx.filter = 'blur(8px) brightness(200%)';
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.drawImage(canvas, 0, 0);
|
||||
const canvas = canvasRef.current;
|
||||
if (canvas) ctx.drawImage(canvas, 0, 0, w, h);
|
||||
ctx.restore();
|
||||
|
||||
animationRef.current = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
resize();
|
||||
initParticles();
|
||||
animationRef.current = requestAnimationFrame(draw);
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}
|
||||
}, [particleCount, rangeY, baseHue, rangeSpeed, backgroundColor]);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createNoise3D } from 'simplex-noise';
|
||||
import { sanitizeColor, clampNumber } from './types';
|
||||
import { sanitizeColor, clampNumber, safeSelect } from './types';
|
||||
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||
|
||||
interface Props {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
interface WavyState {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
noise3D: ReturnType<typeof createNoise3D>;
|
||||
nt: number;
|
||||
w: number;
|
||||
h: number;
|
||||
dpr: number;
|
||||
}
|
||||
|
||||
export default function WavyBackground({ settings }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number>(0);
|
||||
const stateRef = useRef<WavyState | null>(null);
|
||||
|
||||
const speed = (settings.speed as string) ?? 'fast';
|
||||
const speed = safeSelect(settings.speed, ['slow', 'fast'] as const, 'fast');
|
||||
const waveWidth = clampNumber(settings.waveWidth, 5, 200, 50);
|
||||
const blur = clampNumber(settings.blur, 0, 50, 10);
|
||||
const waveOpacity = clampNumber(settings.waveOpacity, 0.05, 1, 0.5);
|
||||
@@ -21,55 +33,73 @@ export default function WavyBackground({ settings }: Props) {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const dpr = getMobileDpr();
|
||||
const parent = canvas.parentElement;
|
||||
const w = parent?.offsetWidth ?? window.innerWidth;
|
||||
const h = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = w * dpr;
|
||||
canvas.height = h * dpr;
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const noise3D = createNoise3D();
|
||||
let nt = 0;
|
||||
const speedVal = speed === 'slow' ? 0.001 : 0.002;
|
||||
const waveCount = 5;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const effectiveBlur = isMobile ? Math.min(blur, 4) : blur;
|
||||
ctx.filter = effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : 'none';
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
||||
ctx.filter = `blur(${blur}px)`;
|
||||
stateRef.current = { ctx, noise3D: createNoise3D(), nt: 0, w, h, dpr };
|
||||
|
||||
const onResize = () => {
|
||||
const nw = parent?.offsetWidth ?? window.innerWidth;
|
||||
const nh = parent?.offsetHeight ?? window.innerHeight;
|
||||
canvas.width = nw * dpr;
|
||||
canvas.height = nh * dpr;
|
||||
canvas.style.width = `${nw}px`;
|
||||
canvas.style.height = `${nh}px`;
|
||||
if (stateRef.current) {
|
||||
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const eb = isMobile ? Math.min(blur, 4) : blur;
|
||||
stateRef.current.ctx.filter = eb > 0 ? `blur(${eb}px)` : 'none';
|
||||
stateRef.current.w = nw;
|
||||
stateRef.current.h = nh;
|
||||
}
|
||||
};
|
||||
|
||||
const drawWave = (_n: number) => {
|
||||
nt += speedVal;
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [blur]);
|
||||
|
||||
const speedVal = speed === 'slow' ? 0.001 : 0.002;
|
||||
const waveCount = isMobile ? 3 : 5;
|
||||
const step = isMobile ? 8 : 5;
|
||||
|
||||
useAnimationLoop(() => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
|
||||
const { ctx, noise3D, w, h } = state;
|
||||
state.nt += speedVal;
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = backgroundFill;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
for (let i = 0; i < waveCount; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = waveWidth;
|
||||
ctx.strokeStyle = colors[i % colors.length];
|
||||
ctx.globalAlpha = waveOpacity;
|
||||
|
||||
for (let x = 0; x < canvas.width; x += 5) {
|
||||
const y = noise3D(x / 800, 0.3 * i, nt) * 100;
|
||||
ctx.lineTo(x, y + canvas.height * 0.5);
|
||||
for (let x = 0; x < w; x += step) {
|
||||
const y = noise3D(x / 800, 0.3 * i, state.nt) * 100;
|
||||
ctx.lineTo(x, y + h * 0.5);
|
||||
}
|
||||
|
||||
ctx.stroke();
|
||||
ctx.closePath();
|
||||
}
|
||||
};
|
||||
|
||||
const animate = () => {
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fillStyle = backgroundFill;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
drawWave(5);
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
resize();
|
||||
animationRef.current = requestAnimationFrame(animate);
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationRef.current);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [speed, waveWidth, blur, waveOpacity, backgroundFill]);
|
||||
|
||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||
|
||||
22
src/constants/charts.ts
Normal file
22
src/constants/charts.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const CHART_COMMON = {
|
||||
KOPEKS_DIVISOR: 100,
|
||||
GRADIENT: {
|
||||
START_OFFSET: '5%',
|
||||
END_OFFSET: '95%',
|
||||
START_OPACITY: 0.3,
|
||||
END_OPACITY: 0,
|
||||
},
|
||||
AXIS: {
|
||||
TICK_FONT_SIZE: 11,
|
||||
},
|
||||
TOOLTIP: {
|
||||
BORDER_RADIUS: '8px',
|
||||
FONT_SIZE: '12px',
|
||||
},
|
||||
STROKE_WIDTH: 2,
|
||||
GRID_DASH: '3 3',
|
||||
STALE_TIME: 60_000,
|
||||
CHART: {
|
||||
MARGIN: { top: 5, right: 10, left: 0, bottom: 5 },
|
||||
},
|
||||
} as const;
|
||||
33
src/constants/partner.ts
Normal file
33
src/constants/partner.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { CHART_COMMON } from './charts';
|
||||
|
||||
export const PARTNER_STATS = {
|
||||
/** Chart visual configuration */
|
||||
CHART: {
|
||||
...CHART_COMMON.CHART,
|
||||
HEIGHT: 200,
|
||||
},
|
||||
/** Kopeks-to-currency divisor */
|
||||
KOPEKS_DIVISOR: CHART_COMMON.KOPEKS_DIVISOR,
|
||||
/** Copy feedback duration in ms */
|
||||
COPY_FEEDBACK_MS: 2000,
|
||||
/** Gradient stop offsets */
|
||||
GRADIENT: CHART_COMMON.GRADIENT,
|
||||
/** Axis tick styling */
|
||||
AXIS: {
|
||||
TICK_FONT_SIZE: CHART_COMMON.AXIS.TICK_FONT_SIZE,
|
||||
EARNINGS_WIDTH: 45,
|
||||
REFERRALS_WIDTH: 30,
|
||||
},
|
||||
/** Tooltip styling */
|
||||
TOOLTIP: CHART_COMMON.TOOLTIP,
|
||||
/** Area stroke width */
|
||||
STROKE_WIDTH: CHART_COMMON.STROKE_WIDTH,
|
||||
/** Grid dash array */
|
||||
GRID_DASH: CHART_COMMON.GRID_DASH,
|
||||
/** Skeleton count for loading state */
|
||||
SKELETON_COUNT: 3,
|
||||
/** Maximum conversion rate for progress bar */
|
||||
MAX_CONVERSION_RATE: 100,
|
||||
/** Stale time for campaign stats query (ms) */
|
||||
STATS_STALE_TIME: CHART_COMMON.STALE_TIME,
|
||||
} as const;
|
||||
39
src/constants/salesStats.ts
Normal file
39
src/constants/salesStats.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { CHART_COMMON } from './charts';
|
||||
|
||||
export const SALES_STATS = {
|
||||
CHART: {
|
||||
...CHART_COMMON.CHART,
|
||||
HEIGHT: 220,
|
||||
PIE_HEIGHT: 250,
|
||||
},
|
||||
KOPEKS_DIVISOR: CHART_COMMON.KOPEKS_DIVISOR,
|
||||
GRADIENT: CHART_COMMON.GRADIENT,
|
||||
AXIS: {
|
||||
TICK_FONT_SIZE: CHART_COMMON.AXIS.TICK_FONT_SIZE,
|
||||
WIDTH: 40,
|
||||
},
|
||||
TOOLTIP: CHART_COMMON.TOOLTIP,
|
||||
STROKE_WIDTH: CHART_COMMON.STROKE_WIDTH,
|
||||
GRID_DASH: CHART_COMMON.GRID_DASH,
|
||||
STALE_TIME: CHART_COMMON.STALE_TIME,
|
||||
DEFAULT_PERIOD: 30,
|
||||
PERIOD_PRESETS: [7, 30, 90, 0] as const,
|
||||
PROVIDER_COLORS: {
|
||||
telegram: '#229ED9',
|
||||
email: '#34d399',
|
||||
vk: '#4C75A3',
|
||||
yandex: '#FC3F1D',
|
||||
google: '#4285F4',
|
||||
discord: '#5865F2',
|
||||
} as const,
|
||||
BAR_COLORS: [
|
||||
'#34d399',
|
||||
'#818cf8',
|
||||
'#f59e0b',
|
||||
'#ec4899',
|
||||
'#06b6d4',
|
||||
'#8b5cf6',
|
||||
'#f97316',
|
||||
'#14b8a6',
|
||||
] as const,
|
||||
} as const;
|
||||
140
src/hooks/useAnimationLoop.ts
Normal file
140
src/hooks/useAnimationLoop.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
const TARGET_FPS = isMobile ? 30 : 60;
|
||||
const FRAME_INTERVAL = 1000 / TARGET_FPS;
|
||||
|
||||
/**
|
||||
* Shared animation loop hook with built-in performance safeguards:
|
||||
* - FPS throttling: 30 FPS on mobile, 60 FPS on desktop
|
||||
* - Page Visibility API: fully stops RAF when tab/app is hidden
|
||||
* - Telegram Mini App: fully stops RAF when isActive = false
|
||||
* - Auto-cleanup on unmount
|
||||
*/
|
||||
export function useAnimationLoop(
|
||||
callback: (time: number, delta: number) => void,
|
||||
deps: React.DependencyList,
|
||||
) {
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
let animId = 0;
|
||||
let lastTime = 0;
|
||||
let docHidden = document.hidden;
|
||||
let tgInactive = false;
|
||||
|
||||
const isPaused = () => docHidden || tgInactive;
|
||||
|
||||
const loop = (time: number) => {
|
||||
const delta = time - lastTime;
|
||||
if (delta >= FRAME_INTERVAL) {
|
||||
lastTime = time - (delta % FRAME_INTERVAL);
|
||||
callbackRef.current(time, delta);
|
||||
}
|
||||
animId = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
if (animId) return; // already running
|
||||
lastTime = performance.now();
|
||||
animId = requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (animId) {
|
||||
cancelAnimationFrame(animId);
|
||||
animId = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
docHidden = document.hidden;
|
||||
if (isPaused()) stop();
|
||||
else start();
|
||||
};
|
||||
|
||||
const tg = window.Telegram?.WebApp;
|
||||
const onTgActivated = () => {
|
||||
tgInactive = false;
|
||||
if (!isPaused()) start();
|
||||
};
|
||||
const onTgDeactivated = () => {
|
||||
tgInactive = true;
|
||||
stop();
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
if (tg) {
|
||||
tg.onEvent?.('activated', onTgActivated);
|
||||
tg.onEvent?.('deactivated', onTgDeactivated);
|
||||
}
|
||||
|
||||
// Start the loop (only if page is currently visible)
|
||||
if (!isPaused()) start();
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
if (tg) {
|
||||
tg.offEvent?.('activated', onTgActivated);
|
||||
tg.offEvent?.('deactivated', onTgDeactivated);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether animations should be paused (tab hidden or Telegram deactivated).
|
||||
* Use for CSS/Framer Motion components that can't use useAnimationLoop.
|
||||
*/
|
||||
export function useAnimationPause(): boolean {
|
||||
const [paused, setPaused] = useState(() =>
|
||||
typeof document !== 'undefined' ? document.hidden : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let docHidden = document.hidden;
|
||||
let tgInactive = false;
|
||||
|
||||
const update = () => setPaused(docHidden || tgInactive);
|
||||
|
||||
const onVisibility = () => {
|
||||
docHidden = document.hidden;
|
||||
update();
|
||||
};
|
||||
|
||||
const tg = window.Telegram?.WebApp;
|
||||
const onActivated = () => {
|
||||
tgInactive = false;
|
||||
update();
|
||||
};
|
||||
const onDeactivated = () => {
|
||||
tgInactive = true;
|
||||
update();
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
if (tg) {
|
||||
tg.onEvent?.('activated', onActivated);
|
||||
tg.onEvent?.('deactivated', onDeactivated);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
if (tg) {
|
||||
tg.offEvent?.('activated', onActivated);
|
||||
tg.offEvent?.('deactivated', onDeactivated);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return paused;
|
||||
}
|
||||
|
||||
/** Returns a reduced DPR for mobile canvas rendering (saves 4x GPU work on retina) */
|
||||
export function getMobileDpr(): number {
|
||||
if (isMobile) return Math.min(devicePixelRatio, 1.5);
|
||||
return devicePixelRatio || 1;
|
||||
}
|
||||
84
src/hooks/useChartColors.ts
Normal file
84
src/hooks/useChartColors.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { rgbToHex } from '../utils/colorConversion';
|
||||
|
||||
/** CSS variable names for chart theme colors */
|
||||
const CSS_VARS = {
|
||||
earnings: '--color-success-400',
|
||||
referrals: '--color-accent-400',
|
||||
grid: '--color-dark-700',
|
||||
tooltipBg: '--color-dark-800',
|
||||
tooltipBorder: '--color-dark-600',
|
||||
tick: '--color-dark-400',
|
||||
label: '--color-dark-300',
|
||||
} as const;
|
||||
|
||||
/** Fallback hex colors (default dark theme) */
|
||||
const FALLBACK: Record<keyof typeof CSS_VARS, string> = {
|
||||
earnings: '#34d399',
|
||||
referrals: '#818cf8',
|
||||
grid: '#374151',
|
||||
tooltipBg: '#1f2937',
|
||||
tooltipBorder: '#4b5563',
|
||||
tick: '#9ca3af',
|
||||
label: '#d1d5db',
|
||||
};
|
||||
|
||||
function cssVarToHex(varName: string, fallback: string): string {
|
||||
if (typeof document === 'undefined') return fallback;
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
if (!raw) return fallback;
|
||||
|
||||
const parts = raw.split(',').map((s) => parseInt(s.trim(), 10));
|
||||
if (parts.length !== 3 || parts.some(isNaN)) return fallback;
|
||||
|
||||
return rgbToHex(parts[0], parts[1], parts[2]);
|
||||
}
|
||||
|
||||
export interface ChartColors {
|
||||
earnings: string;
|
||||
referrals: string;
|
||||
grid: string;
|
||||
tooltipBg: string;
|
||||
tooltipBorder: string;
|
||||
tick: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads theme-aware colors from CSS custom properties for use in Recharts.
|
||||
* Returns hex values derived from the current theme's CSS variables,
|
||||
* adapting to light/dark mode and admin-customized accent colors.
|
||||
*
|
||||
* Colors are memoized and only recomputed when the document element's
|
||||
* class attribute changes (i.e. theme switch).
|
||||
*/
|
||||
export function useChartColors(): ChartColors {
|
||||
const [themeKey, setThemeKey] = useState(() =>
|
||||
typeof document !== 'undefined' ? document.documentElement.className : '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
setThemeKey(document.documentElement.className);
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const colors = useMemo<ChartColors>(
|
||||
() => ({
|
||||
earnings: cssVarToHex(CSS_VARS.earnings, FALLBACK.earnings),
|
||||
referrals: cssVarToHex(CSS_VARS.referrals, FALLBACK.referrals),
|
||||
grid: cssVarToHex(CSS_VARS.grid, FALLBACK.grid),
|
||||
tooltipBg: cssVarToHex(CSS_VARS.tooltipBg, FALLBACK.tooltipBg),
|
||||
tooltipBorder: cssVarToHex(CSS_VARS.tooltipBorder, FALLBACK.tooltipBorder),
|
||||
tick: cssVarToHex(CSS_VARS.tick, FALLBACK.tick),
|
||||
label: cssVarToHex(CSS_VARS.label, FALLBACK.label),
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[themeKey],
|
||||
);
|
||||
|
||||
return colors;
|
||||
}
|
||||
@@ -318,6 +318,11 @@
|
||||
"price": "Price",
|
||||
"noSubscription": "You don't have an active subscription",
|
||||
"getSubscription": "Get Subscription",
|
||||
"cta": {
|
||||
"expiredHint": "Choose a plan and pay",
|
||||
"trialHint": "More traffic and devices",
|
||||
"activeHint": "Renewal and plan change"
|
||||
},
|
||||
"connectionInfo": "Connection Info",
|
||||
"copyLink": "Copy Link",
|
||||
"copied": "Copied!",
|
||||
@@ -422,7 +427,10 @@
|
||||
"notConfigured": "Connection methods are not configured yet",
|
||||
"notConfiguredUser": "Connection setup is in progress. Please check back later.",
|
||||
"notConfiguredAdmin": "No connection apps have been configured. Set them up in the Apps settings.",
|
||||
"goToApps": "Go to Apps settings"
|
||||
"goToApps": "Go to Apps settings",
|
||||
"qrTitle": "Subscription QR Code",
|
||||
"qrScanHint": "Scan to connect",
|
||||
"qrButton": "QR Code"
|
||||
},
|
||||
"myDevices": "My Devices",
|
||||
"noDevices": "No connected devices",
|
||||
@@ -694,6 +702,8 @@
|
||||
},
|
||||
"yourReferrals": "Your Referrals",
|
||||
"noReferrals": "No referrals yet. Share your link to invite friends!",
|
||||
"anonymousUser": "User #{{id}}",
|
||||
"anonymousReferral": "Referral",
|
||||
"user": "User",
|
||||
"joined": "Joined",
|
||||
"hasPaid": "Has Paid",
|
||||
@@ -742,6 +752,28 @@
|
||||
"tariff": "Bonus tariff",
|
||||
"tariffDesc": "Free tariff plan"
|
||||
},
|
||||
"stats": {
|
||||
"registrations": "Registrations",
|
||||
"referrals": "Referrals",
|
||||
"earnings": "Earnings",
|
||||
"conversionRate": "Conversion",
|
||||
"today": "Today",
|
||||
"week": "Week",
|
||||
"month": "Month",
|
||||
"showDetails": "Details",
|
||||
"hideDetails": "Collapse",
|
||||
"vsLastWeek": "vs last week",
|
||||
"topReferrals": "Top Referrals",
|
||||
"noData": "No data",
|
||||
"dailyChart": "30-day trends",
|
||||
"periodComparison": "Period comparison",
|
||||
"noReferrals": "No referrals yet",
|
||||
"paid": "Paid",
|
||||
"pending": "Pending",
|
||||
"active": "Active",
|
||||
"referralsCount": "Referrals",
|
||||
"earningsAmount": "Earned"
|
||||
},
|
||||
"fields": {
|
||||
"companyName": "Company Name",
|
||||
"companyNamePlaceholder": "Your company or brand name",
|
||||
@@ -752,7 +784,9 @@
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Tell us about your audience and how you plan to promote...",
|
||||
"expectedReferrals": "Expected Monthly Referrals",
|
||||
"expectedReferralsPlaceholder": "Estimated number per month"
|
||||
"expectedReferralsPlaceholder": "Estimated number per month",
|
||||
"desiredCommission": "Desired Commission (%)",
|
||||
"desiredCommissionPlaceholder": "From 1 to 100%"
|
||||
}
|
||||
},
|
||||
"withdrawal": {
|
||||
@@ -773,9 +807,9 @@
|
||||
"submitRequest": "Submit Request",
|
||||
"requestError": "Failed to create withdrawal request. Please try again.",
|
||||
"fields": {
|
||||
"amount": "Amount (kopeks)",
|
||||
"amountPlaceholder": "Enter amount in kopeks",
|
||||
"amountHint": "Enter the amount in kopeks (100 kopeks = 1 {{currency}})",
|
||||
"amount": "Amount",
|
||||
"amountPlaceholder": "Enter amount in {{currency}}",
|
||||
"amountHint": "Minimum: {{min}} {{currency}}",
|
||||
"paymentDetails": "Payment Details",
|
||||
"paymentDetailsPlaceholder": "Card number, wallet address, or other payment info..."
|
||||
},
|
||||
@@ -858,7 +892,8 @@
|
||||
"cannotSpin": "Cannot spin right now",
|
||||
"insufficientBalance": "Insufficient balance",
|
||||
"topUpRequired": "Top up your balance to pay for spin",
|
||||
"starsNotAvailable": "Stars payment not available. Open the app via Telegram."
|
||||
"starsNotAvailable": "Stars payment not available. Open the app via Telegram.",
|
||||
"noSubscription": "An active subscription is required to spin the wheel. Activate your subscription first."
|
||||
},
|
||||
"payWithStars": "Pay with Stars",
|
||||
"payWithDays": "Pay with days",
|
||||
@@ -1007,7 +1042,8 @@
|
||||
"roles": "Roles",
|
||||
"roleAssign": "Role Assignment",
|
||||
"policies": "Access Policies",
|
||||
"auditLog": "Audit Log"
|
||||
"auditLog": "Audit Log",
|
||||
"salesStats": "Sales Statistics"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Admin Panel",
|
||||
@@ -1039,7 +1075,87 @@
|
||||
"rolesDesc": "Manage admin roles and permissions",
|
||||
"roleAssignDesc": "Assign and revoke user roles",
|
||||
"policiesDesc": "Configure ABAC access policies",
|
||||
"auditLogDesc": "Review system activity and access history"
|
||||
"auditLogDesc": "Review system activity and access history",
|
||||
"salesStatsDesc": "Sales analytics and trends"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "Sales Statistics",
|
||||
"subtitle": "Revenue, trials, subscriptions, and renewals",
|
||||
"period": {
|
||||
"week": "7 days",
|
||||
"month": "30 days",
|
||||
"quarter": "90 days",
|
||||
"all": "All time",
|
||||
"custom": "Custom"
|
||||
},
|
||||
"tabs": {
|
||||
"trials": "Trials",
|
||||
"sales": "Sales",
|
||||
"renewals": "Renewals",
|
||||
"addons": "Add-ons",
|
||||
"deposits": "Deposits"
|
||||
},
|
||||
"summary": {
|
||||
"revenue": "Revenue",
|
||||
"activeSubs": "Active Subs",
|
||||
"activeTrials": "Active Trials",
|
||||
"newTrials": "New Trials",
|
||||
"conversion": "Conversion",
|
||||
"renewals": "Renewals",
|
||||
"addonRevenue": "Add-on Revenue"
|
||||
},
|
||||
"trials": {
|
||||
"total": "Total Trials",
|
||||
"totalRegistrations": "Registrations",
|
||||
"trialsIssued": "Trials Issued",
|
||||
"conversion": "Conversion Rate",
|
||||
"avgDuration": "Avg Duration",
|
||||
"byProvider": "By Registration Source",
|
||||
"dailyChart": "Daily Registrations & Trials",
|
||||
"registrations": "Registrations"
|
||||
},
|
||||
"sales": {
|
||||
"totalSales": "Total Sales",
|
||||
"avgOrder": "Avg Order",
|
||||
"topTariff": "Top Tariff",
|
||||
"byTariff": "Sales by Tariff",
|
||||
"byPeriod": "By Subscription Period",
|
||||
"dailyChart": "Daily Sales",
|
||||
"dailyByTariff": "Sales by Tariff per Day",
|
||||
"subscriptions": "Subscriptions"
|
||||
},
|
||||
"renewals": {
|
||||
"total": "Total Renewals",
|
||||
"rate": "Renewal Rate",
|
||||
"revenue": "Revenue",
|
||||
"comparison": "Period Comparison",
|
||||
"currentPeriod": "Current Period",
|
||||
"previousPeriod": "Previous Period",
|
||||
"dailyChart": "Daily Renewals",
|
||||
"renewals": "Renewals"
|
||||
},
|
||||
"addons": {
|
||||
"totalPurchases": "Total Purchases",
|
||||
"totalGb": "Total Traffic",
|
||||
"revenue": "Revenue",
|
||||
"devicePurchases": "Device Purchases",
|
||||
"deviceRevenue": "Device Revenue",
|
||||
"trafficPurchases": "Traffic",
|
||||
"devicePurchasesDaily": "Devices",
|
||||
"byPackage": "By Package Size",
|
||||
"dailyChart": "Daily Add-on Purchases",
|
||||
"purchases": "Purchases"
|
||||
},
|
||||
"deposits": {
|
||||
"totalDeposits": "Total Deposits",
|
||||
"totalAmount": "Total Amount",
|
||||
"avgDeposit": "Avg Deposit",
|
||||
"byMethod": "By Payment Method",
|
||||
"dailyChart": "Daily Deposits",
|
||||
"dailyByMethod": "Daily Deposits by Payment Method",
|
||||
"revenue": "Revenue"
|
||||
},
|
||||
"loadError": "Failed to load statistics"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "Traffic Usage",
|
||||
@@ -1980,7 +2096,15 @@
|
||||
"users": "Users",
|
||||
"noUsers": "No registered users",
|
||||
"paid": "Paid",
|
||||
"hasSub": "Has sub"
|
||||
"hasSub": "Has sub",
|
||||
"dailyChart": "30-day trends",
|
||||
"chartRegistrations": "Registrations",
|
||||
"chartRevenue": "Revenue",
|
||||
"totalDeposits": "Total Deposits",
|
||||
"totalSpending": "Subscription Spending",
|
||||
"periodComparison": "Period comparison",
|
||||
"vsLastWeek": "vs last week",
|
||||
"topRegistrations": "Top registrations"
|
||||
},
|
||||
"users": {
|
||||
"title": "Campaign Users",
|
||||
@@ -2042,6 +2166,7 @@
|
||||
"balance": "Balance",
|
||||
"totalReferrals": "Total referrals",
|
||||
"totalEarnings": "Total earnings",
|
||||
"requestedAmount": "Requested Amount",
|
||||
"createdAt": "Created",
|
||||
"paymentDetails": "Payment Details",
|
||||
"noPaymentDetails": "No payment details provided",
|
||||
@@ -2097,7 +2222,8 @@
|
||||
"website": "Website",
|
||||
"channel": "Channel",
|
||||
"description": "Description",
|
||||
"expectedReferrals": "Expected referrals/mo"
|
||||
"expectedReferrals": "Expected referrals/mo",
|
||||
"desiredCommission": "Desired commission"
|
||||
},
|
||||
"approveDialog": {
|
||||
"title": "Approve Application",
|
||||
|
||||
@@ -327,7 +327,10 @@
|
||||
"notConfigured": "روشهای اتصال هنوز پیکربندی نشدهاند",
|
||||
"notConfiguredUser": "تنظیمات اتصال در حال انجام است. لطفاً بعداً مراجعه کنید.",
|
||||
"notConfiguredAdmin": "هیچ برنامه اتصالی پیکربندی نشده است. آنها را در تنظیمات برنامهها تنظیم کنید.",
|
||||
"goToApps": "رفتن به تنظیمات برنامهها"
|
||||
"goToApps": "رفتن به تنظیمات برنامهها",
|
||||
"qrTitle": "کد QR اشتراک",
|
||||
"qrScanHint": "برای اتصال با دوربین اسکن کنید",
|
||||
"qrButton": "کد QR"
|
||||
},
|
||||
"additionalOptions": {
|
||||
"title": "گزینههای اضافی",
|
||||
@@ -533,6 +536,8 @@
|
||||
},
|
||||
"yourReferrals": "معرفیهای شما",
|
||||
"noReferrals": "هنوز معرفی ندارید. لینک را به اشتراک بگذارید!",
|
||||
"anonymousUser": "کاربر #{{id}}",
|
||||
"anonymousReferral": "معرفیشده",
|
||||
"user": "کاربر",
|
||||
"joined": "تاریخ عضویت",
|
||||
"hasPaid": "پرداخت کرده",
|
||||
@@ -581,6 +586,28 @@
|
||||
"tariff": "تعرفه جایزه",
|
||||
"tariffDesc": "طرح تعرفه رایگان"
|
||||
},
|
||||
"stats": {
|
||||
"registrations": "ثبتنام",
|
||||
"referrals": "معرفیشدهها",
|
||||
"earnings": "درآمد",
|
||||
"conversionRate": "نرخ تبدیل",
|
||||
"today": "امروز",
|
||||
"week": "هفته",
|
||||
"month": "ماه",
|
||||
"showDetails": "جزئیات",
|
||||
"hideDetails": "بستن",
|
||||
"vsLastWeek": "مقایسه با هفته قبل",
|
||||
"topReferrals": "برترین معرفیشدهها",
|
||||
"noData": "دادهای نیست",
|
||||
"dailyChart": "روند ۳۰ روزه",
|
||||
"periodComparison": "مقایسه دورهها",
|
||||
"noReferrals": "هنوز معرفیشدهای نیست",
|
||||
"paid": "پرداخت شده",
|
||||
"pending": "در انتظار",
|
||||
"active": "فعال",
|
||||
"referralsCount": "تعداد معرفی",
|
||||
"earningsAmount": "کسب شده"
|
||||
},
|
||||
"fields": {
|
||||
"companyName": "نام شرکت",
|
||||
"companyNamePlaceholder": "نام شرکت یا برند شما",
|
||||
@@ -591,7 +618,9 @@
|
||||
"description": "توضیحات",
|
||||
"descriptionPlaceholder": "درباره مخاطبان خود و نحوه تبلیغ بگویید...",
|
||||
"expectedReferrals": "تعداد معرفی ماهانه مورد انتظار",
|
||||
"expectedReferralsPlaceholder": "تعداد تخمینی در ماه"
|
||||
"expectedReferralsPlaceholder": "تعداد تخمینی در ماه",
|
||||
"desiredCommission": "درصد کمیسیون مورد نظر",
|
||||
"desiredCommissionPlaceholder": "از ۱ تا ۱۰۰٪"
|
||||
}
|
||||
},
|
||||
"withdrawal": {
|
||||
@@ -612,9 +641,9 @@
|
||||
"submitRequest": "ارسال درخواست",
|
||||
"requestError": "ایجاد درخواست برداشت ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||
"fields": {
|
||||
"amount": "مبلغ (کوپک)",
|
||||
"amountPlaceholder": "مبلغ را به کوپک وارد کنید",
|
||||
"amountHint": "مبلغ را به کوپک وارد کنید (۱۰۰ کوپک = ۱ {{currency}})",
|
||||
"amount": "مبلغ",
|
||||
"amountPlaceholder": "مبلغ را به {{currency}} وارد کنید",
|
||||
"amountHint": "حداقل: {{min}} {{currency}}",
|
||||
"paymentDetails": "اطلاعات پرداخت",
|
||||
"paymentDetailsPlaceholder": "شماره کارت، آدرس کیف پول یا سایر اطلاعات پرداخت..."
|
||||
},
|
||||
@@ -694,7 +723,8 @@
|
||||
"cannotSpin": "فعلاً امکان چرخش نیست",
|
||||
"insufficientBalance": "موجودی کافی نیست",
|
||||
"topUpRequired": "برای پرداخت شارژ کنید",
|
||||
"starsNotAvailable": "پرداخت Stars در دسترس نیست. برنامه را از طریق تلگرام باز کنید."
|
||||
"starsNotAvailable": "پرداخت Stars در دسترس نیست. برنامه را از طریق تلگرام باز کنید.",
|
||||
"noSubscription": "برای چرخاندن گردونه نیاز به اشتراک فعال دارید. ابتدا اشتراک خود را فعال کنید."
|
||||
},
|
||||
"payWithStars": "پرداخت {stars} Stars",
|
||||
"processingPayment": "در حال پردازش...",
|
||||
@@ -835,7 +865,8 @@
|
||||
"roles": "نقشها",
|
||||
"roleAssign": "تخصیص نقش",
|
||||
"policies": "سیاستهای دسترسی",
|
||||
"auditLog": "گزارش بازرسی"
|
||||
"auditLog": "گزارش بازرسی",
|
||||
"salesStats": "آمار فروش"
|
||||
},
|
||||
"panel": {
|
||||
"title": "پنل مدیریت",
|
||||
@@ -867,7 +898,87 @@
|
||||
"rolesDesc": "مدیریت نقشها و مجوزهای مدیران",
|
||||
"roleAssignDesc": "تخصیص و لغو نقشهای کاربران",
|
||||
"policiesDesc": "تنظیم سیاستهای کنترل دسترسی ABAC",
|
||||
"auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی"
|
||||
"auditLogDesc": "بررسی فعالیت سیستم و تاریخچه دسترسی",
|
||||
"salesStatsDesc": "تحلیل فروش و روندها"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "آمار فروش",
|
||||
"subtitle": "درآمد، آزمایشی، اشتراک و تمدید",
|
||||
"period": {
|
||||
"week": "۷ روز",
|
||||
"month": "۳۰ روز",
|
||||
"quarter": "۹۰ روز",
|
||||
"all": "همه",
|
||||
"custom": "سفارشی"
|
||||
},
|
||||
"tabs": {
|
||||
"trials": "آزمایشی",
|
||||
"sales": "فروش",
|
||||
"renewals": "تمدید",
|
||||
"addons": "خدمات اضافی",
|
||||
"deposits": "شارژ"
|
||||
},
|
||||
"summary": {
|
||||
"revenue": "درآمد",
|
||||
"activeSubs": "اشتراک فعال",
|
||||
"activeTrials": "آزمایشی فعال",
|
||||
"newTrials": "آزمایشی جدید",
|
||||
"conversion": "تبدیل",
|
||||
"renewals": "تمدید",
|
||||
"addonRevenue": "درآمد خدمات اضافی"
|
||||
},
|
||||
"trials": {
|
||||
"total": "کل آزمایشی",
|
||||
"totalRegistrations": "ثبتنامها",
|
||||
"trialsIssued": "آزمایشی صادرشده",
|
||||
"conversion": "نرخ تبدیل",
|
||||
"avgDuration": "میانگین مدت",
|
||||
"byProvider": "بر اساس منبع",
|
||||
"dailyChart": "ثبتنام و آزمایشی روزانه",
|
||||
"registrations": "ثبتنام"
|
||||
},
|
||||
"sales": {
|
||||
"totalSales": "کل فروش",
|
||||
"avgOrder": "میانگین سفارش",
|
||||
"topTariff": "تعرفه برتر",
|
||||
"byTariff": "بر اساس تعرفه",
|
||||
"byPeriod": "بر اساس دوره",
|
||||
"dailyChart": "فروش روزانه",
|
||||
"dailyByTariff": "فروش روزانه بر اساس تعرفه",
|
||||
"subscriptions": "اشتراکها"
|
||||
},
|
||||
"renewals": {
|
||||
"total": "کل تمدیدها",
|
||||
"rate": "نرخ تمدید",
|
||||
"revenue": "درآمد",
|
||||
"comparison": "مقایسه دوره",
|
||||
"currentPeriod": "دوره فعلی",
|
||||
"previousPeriod": "دوره قبلی",
|
||||
"dailyChart": "تمدید روزانه",
|
||||
"renewals": "تمدید"
|
||||
},
|
||||
"addons": {
|
||||
"totalPurchases": "کل خرید",
|
||||
"totalGb": "کل ترافیک",
|
||||
"revenue": "درآمد",
|
||||
"devicePurchases": "خرید دستگاه",
|
||||
"deviceRevenue": "درآمد دستگاه",
|
||||
"trafficPurchases": "ترافیک",
|
||||
"devicePurchasesDaily": "دستگاه",
|
||||
"byPackage": "بر اساس بسته",
|
||||
"dailyChart": "خرید روزانه خدمات اضافی",
|
||||
"purchases": "خرید"
|
||||
},
|
||||
"deposits": {
|
||||
"totalDeposits": "کل شارژ",
|
||||
"totalAmount": "مبلغ کل",
|
||||
"avgDeposit": "میانگین شارژ",
|
||||
"byMethod": "بر اساس روش پرداخت",
|
||||
"dailyChart": "شارژ روزانه",
|
||||
"dailyByMethod": "شارژ روزانه بر اساس روش پرداخت",
|
||||
"revenue": "درآمد"
|
||||
},
|
||||
"loadError": "بارگذاری آمار ناموفق بود"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "مصرف ترافیک",
|
||||
@@ -1664,7 +1775,15 @@
|
||||
"users": "کاربران",
|
||||
"noUsers": "کاربر ثبتنام شدهای وجود ندارد",
|
||||
"paid": "پرداخت کرده",
|
||||
"hasSub": "دارای اشتراک"
|
||||
"hasSub": "دارای اشتراک",
|
||||
"dailyChart": "روند ۳۰ روزه",
|
||||
"chartRegistrations": "ثبتنامها",
|
||||
"chartRevenue": "درآمد",
|
||||
"totalDeposits": "کل واریزیها",
|
||||
"totalSpending": "هزینه اشتراک",
|
||||
"periodComparison": "مقایسه دورهها",
|
||||
"vsLastWeek": "نسبت به هفته قبل",
|
||||
"topRegistrations": "بالاترین ثبتنامها"
|
||||
},
|
||||
"users": {
|
||||
"title": "کاربران کمپین",
|
||||
@@ -1714,7 +1833,8 @@
|
||||
"approved": "تأیید شده",
|
||||
"rejected": "رد شده",
|
||||
"completed": "تکمیل شده",
|
||||
"cancelled": "لغو شده"
|
||||
"cancelled": "لغو شده",
|
||||
"unknown": "ناشناخته"
|
||||
},
|
||||
"detail": {
|
||||
"title": "جزئیات برداشت",
|
||||
@@ -1725,6 +1845,7 @@
|
||||
"balance": "موجودی",
|
||||
"totalReferrals": "مجموع معرفیها",
|
||||
"totalEarnings": "مجموع درآمد",
|
||||
"requestedAmount": "مبلغ درخواستی",
|
||||
"createdAt": "تاریخ ایجاد",
|
||||
"paymentDetails": "اطلاعات پرداخت",
|
||||
"noPaymentDetails": "اطلاعات پرداختی ارائه نشده",
|
||||
@@ -1780,7 +1901,8 @@
|
||||
"website": "وبسایت",
|
||||
"channel": "کانال",
|
||||
"description": "توضیحات",
|
||||
"expectedReferrals": "معرفی مورد انتظار/ماه"
|
||||
"expectedReferrals": "معرفی مورد انتظار/ماه",
|
||||
"desiredCommission": "کمیسیون مورد نظر"
|
||||
},
|
||||
"approveDialog": {
|
||||
"title": "تأیید درخواست",
|
||||
|
||||
@@ -336,6 +336,11 @@
|
||||
"price": "Цена",
|
||||
"noSubscription": "У вас нет активной подписки",
|
||||
"getSubscription": "Оформить подписку",
|
||||
"cta": {
|
||||
"expiredHint": "Выберите тариф и оплатите",
|
||||
"trialHint": "Больше трафика и устройств",
|
||||
"activeHint": "Продление и смена тарифа"
|
||||
},
|
||||
"connectionInfo": "Данные для подключения",
|
||||
"copyLink": "Копировать ссылку",
|
||||
"copied": "Скопировано!",
|
||||
@@ -445,7 +450,10 @@
|
||||
"notConfigured": "Способы подключения ещё не настроены",
|
||||
"notConfiguredUser": "Настройка подключения в процессе. Пожалуйста, загляните позже.",
|
||||
"notConfiguredAdmin": "Приложения для подключения не настроены. Настройте их в разделе Приложения.",
|
||||
"goToApps": "Перейти к настройке приложений"
|
||||
"goToApps": "Перейти к настройке приложений",
|
||||
"qrTitle": "QR-код подписки",
|
||||
"qrScanHint": "Отсканируйте для подключения",
|
||||
"qrButton": "QR-код"
|
||||
},
|
||||
"myDevices": "Мои устройства",
|
||||
"noDevices": "Нет подключенных устройств",
|
||||
@@ -722,6 +730,8 @@
|
||||
},
|
||||
"yourReferrals": "Ваши рефералы",
|
||||
"noReferrals": "Пока нет рефералов. Поделитесь ссылкой, чтобы пригласить друзей!",
|
||||
"anonymousUser": "Пользователь #{{id}}",
|
||||
"anonymousReferral": "Реферал",
|
||||
"user": "Пользователь",
|
||||
"joined": "Присоединился",
|
||||
"hasPaid": "Оплатил",
|
||||
@@ -770,6 +780,28 @@
|
||||
"tariff": "Бонусный тариф",
|
||||
"tariffDesc": "Бесплатный тарифный план"
|
||||
},
|
||||
"stats": {
|
||||
"registrations": "Регистрации",
|
||||
"referrals": "Рефералы",
|
||||
"earnings": "Заработок",
|
||||
"conversionRate": "Конверсия",
|
||||
"today": "Сегодня",
|
||||
"week": "Неделя",
|
||||
"month": "Месяц",
|
||||
"showDetails": "Подробнее",
|
||||
"hideDetails": "Свернуть",
|
||||
"vsLastWeek": "vs прошлая неделя",
|
||||
"topReferrals": "Топ рефералы",
|
||||
"noData": "Нет данных",
|
||||
"dailyChart": "Динамика за 30 дней",
|
||||
"periodComparison": "Сравнение периодов",
|
||||
"noReferrals": "Пока нет рефералов",
|
||||
"paid": "Оплатил",
|
||||
"pending": "Ожидание",
|
||||
"active": "Активен",
|
||||
"referralsCount": "Рефералов",
|
||||
"earningsAmount": "Заработано"
|
||||
},
|
||||
"fields": {
|
||||
"companyName": "Название компании",
|
||||
"companyNamePlaceholder": "Название вашей компании или бренда",
|
||||
@@ -780,7 +812,9 @@
|
||||
"description": "Описание",
|
||||
"descriptionPlaceholder": "Расскажите о вашей аудитории и планах продвижения...",
|
||||
"expectedReferrals": "Ожидаемое количество рефералов в месяц",
|
||||
"expectedReferralsPlaceholder": "Примерное количество в месяц"
|
||||
"expectedReferralsPlaceholder": "Примерное количество в месяц",
|
||||
"desiredCommission": "Желаемый процент комиссии",
|
||||
"desiredCommissionPlaceholder": "От 1 до 100%"
|
||||
}
|
||||
},
|
||||
"withdrawal": {
|
||||
@@ -801,9 +835,9 @@
|
||||
"submitRequest": "Отправить запрос",
|
||||
"requestError": "Не удалось создать запрос на вывод. Попробуйте снова.",
|
||||
"fields": {
|
||||
"amount": "Сумма (копейки)",
|
||||
"amountPlaceholder": "Введите сумму в копейках",
|
||||
"amountHint": "Введите сумму в копейках (100 копеек = 1 {{currency}})",
|
||||
"amount": "Сумма",
|
||||
"amountPlaceholder": "Введите сумму в {{currency}}",
|
||||
"amountHint": "Минимум: {{min}} {{currency}}",
|
||||
"paymentDetails": "Реквизиты для оплаты",
|
||||
"paymentDetailsPlaceholder": "Номер карты, адрес кошелька или другие реквизиты..."
|
||||
},
|
||||
@@ -887,7 +921,8 @@
|
||||
"cannotSpin": "Сейчас нельзя крутить",
|
||||
"insufficientBalance": "Недостаточно средств на балансе",
|
||||
"topUpRequired": "Пополните баланс для оплаты спина",
|
||||
"starsNotAvailable": "Оплата Stars недоступна. Откройте приложение через Telegram."
|
||||
"starsNotAvailable": "Оплата Stars недоступна. Откройте приложение через Telegram.",
|
||||
"noSubscription": "Для вращения колеса необходима активная подписка. Активируйте подписку в разделе «Подписка»."
|
||||
},
|
||||
"payWithStars": "Оплатить Stars",
|
||||
"payWithDays": "Оплатить днями",
|
||||
@@ -1028,7 +1063,8 @@
|
||||
"roles": "Роли",
|
||||
"roleAssign": "Назначение ролей",
|
||||
"policies": "Политики доступа",
|
||||
"auditLog": "Журнал аудита"
|
||||
"auditLog": "Журнал аудита",
|
||||
"salesStats": "Статистика продаж"
|
||||
},
|
||||
"panel": {
|
||||
"title": "Панель администратора",
|
||||
@@ -1060,7 +1096,87 @@
|
||||
"rolesDesc": "Управление ролями и правами администраторов",
|
||||
"roleAssignDesc": "Назначение и отзыв ролей пользователей",
|
||||
"policiesDesc": "Настройка политик контроля доступа ABAC",
|
||||
"auditLogDesc": "Просмотр активности системы и истории доступа"
|
||||
"auditLogDesc": "Просмотр активности системы и истории доступа",
|
||||
"salesStatsDesc": "Аналитика продаж и тренды"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "Статистика продаж",
|
||||
"subtitle": "Доход, триалы, подписки и продления",
|
||||
"period": {
|
||||
"week": "7 дней",
|
||||
"month": "30 дней",
|
||||
"quarter": "90 дней",
|
||||
"all": "Всё время",
|
||||
"custom": "Свой период"
|
||||
},
|
||||
"tabs": {
|
||||
"trials": "Триалы",
|
||||
"sales": "Продажи",
|
||||
"renewals": "Продления",
|
||||
"addons": "Доп. услуги",
|
||||
"deposits": "Пополнения"
|
||||
},
|
||||
"summary": {
|
||||
"revenue": "Доход",
|
||||
"activeSubs": "Активные подписки",
|
||||
"activeTrials": "Активные триалы",
|
||||
"newTrials": "Новые триалы",
|
||||
"conversion": "Конверсия",
|
||||
"renewals": "Продления",
|
||||
"addonRevenue": "Доп. услуги"
|
||||
},
|
||||
"trials": {
|
||||
"total": "Всего триалов",
|
||||
"totalRegistrations": "Регистрации",
|
||||
"trialsIssued": "Выданные триалы",
|
||||
"conversion": "Конверсия",
|
||||
"avgDuration": "Средняя длит.",
|
||||
"byProvider": "По источнику регистрации",
|
||||
"dailyChart": "Регистрации и триалы по дням",
|
||||
"registrations": "Регистрации"
|
||||
},
|
||||
"sales": {
|
||||
"totalSales": "Всего продаж",
|
||||
"avgOrder": "Средний чек",
|
||||
"topTariff": "Топ тариф",
|
||||
"byTariff": "Продажи по тарифам",
|
||||
"byPeriod": "По периоду подписки",
|
||||
"dailyChart": "Продажи по дням",
|
||||
"dailyByTariff": "Продажи по тарифам по дням",
|
||||
"subscriptions": "Подписки"
|
||||
},
|
||||
"renewals": {
|
||||
"total": "Всего продлений",
|
||||
"rate": "Доля продлений",
|
||||
"revenue": "Доход",
|
||||
"comparison": "Сравнение периодов",
|
||||
"currentPeriod": "Текущий период",
|
||||
"previousPeriod": "Предыдущий период",
|
||||
"dailyChart": "Продления по дням",
|
||||
"renewals": "Продления"
|
||||
},
|
||||
"addons": {
|
||||
"totalPurchases": "Всего покупок",
|
||||
"totalGb": "Всего трафика",
|
||||
"revenue": "Доход",
|
||||
"devicePurchases": "Покупки устройств",
|
||||
"deviceRevenue": "Доход от устройств",
|
||||
"trafficPurchases": "Трафик",
|
||||
"devicePurchasesDaily": "Устройства",
|
||||
"byPackage": "По размеру пакета",
|
||||
"dailyChart": "Покупки доп. услуг по дням",
|
||||
"purchases": "Покупки"
|
||||
},
|
||||
"deposits": {
|
||||
"totalDeposits": "Всего пополнений",
|
||||
"totalAmount": "Общая сумма",
|
||||
"avgDeposit": "Средний чек",
|
||||
"byMethod": "По способу оплаты",
|
||||
"dailyChart": "Пополнения по дням",
|
||||
"dailyByMethod": "Пополнения по дням по платёжкам",
|
||||
"revenue": "Доход"
|
||||
},
|
||||
"loadError": "Не удалось загрузить статистику"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "Расход трафика",
|
||||
@@ -2501,7 +2617,15 @@
|
||||
"users": "Пользователи",
|
||||
"noUsers": "Нет зарегистрированных пользователей",
|
||||
"paid": "Платил",
|
||||
"hasSub": "Подписка"
|
||||
"hasSub": "Подписка",
|
||||
"dailyChart": "Динамика за 30 дней",
|
||||
"chartRegistrations": "Регистрации",
|
||||
"chartRevenue": "Доход",
|
||||
"totalDeposits": "Всего пополнений",
|
||||
"totalSpending": "Расход на подписки",
|
||||
"periodComparison": "Сравнение периодов",
|
||||
"vsLastWeek": "vs прошлая неделя",
|
||||
"topRegistrations": "Топ регистрации"
|
||||
},
|
||||
"users": {
|
||||
"title": "Пользователи кампании",
|
||||
@@ -2563,6 +2687,7 @@
|
||||
"balance": "Баланс",
|
||||
"totalReferrals": "Всего рефералов",
|
||||
"totalEarnings": "Общий заработок",
|
||||
"requestedAmount": "Запрошенная сумма",
|
||||
"createdAt": "Создано",
|
||||
"paymentDetails": "Реквизиты для оплаты",
|
||||
"noPaymentDetails": "Реквизиты не указаны",
|
||||
@@ -2618,7 +2743,8 @@
|
||||
"website": "Сайт",
|
||||
"channel": "Канал",
|
||||
"description": "Описание",
|
||||
"expectedReferrals": "Ожидаемых рефералов/мес"
|
||||
"expectedReferrals": "Ожидаемых рефералов/мес",
|
||||
"desiredCommission": "Желаемая комиссия"
|
||||
},
|
||||
"approveDialog": {
|
||||
"title": "Одобрить заявку",
|
||||
|
||||
@@ -327,7 +327,10 @@
|
||||
"notConfigured": "连接方式尚未配置",
|
||||
"notConfiguredUser": "连接设置正在进行中,请稍后再来查看。",
|
||||
"notConfiguredAdmin": "尚未配置连接应用。请在应用设置中进行配置。",
|
||||
"goToApps": "前往应用设置"
|
||||
"goToApps": "前往应用设置",
|
||||
"qrTitle": "订阅二维码",
|
||||
"qrScanHint": "使用相机扫描以连接",
|
||||
"qrButton": "二维码"
|
||||
},
|
||||
"additionalOptions": {
|
||||
"title": "附加选项",
|
||||
@@ -533,6 +536,8 @@
|
||||
},
|
||||
"yourReferrals": "您的推荐",
|
||||
"noReferrals": "暂无推荐。分享链接邀请好友!",
|
||||
"anonymousUser": "用户 #{{id}}",
|
||||
"anonymousReferral": "推荐人",
|
||||
"user": "用户",
|
||||
"joined": "加入时间",
|
||||
"hasPaid": "已付款",
|
||||
@@ -581,6 +586,28 @@
|
||||
"tariff": "奖励套餐",
|
||||
"tariffDesc": "免费套餐方案"
|
||||
},
|
||||
"stats": {
|
||||
"registrations": "注册",
|
||||
"referrals": "推荐",
|
||||
"earnings": "收入",
|
||||
"conversionRate": "转化率",
|
||||
"today": "今天",
|
||||
"week": "本周",
|
||||
"month": "本月",
|
||||
"showDetails": "详情",
|
||||
"hideDetails": "收起",
|
||||
"vsLastWeek": "对比上周",
|
||||
"topReferrals": "热门推荐",
|
||||
"noData": "暂无数据",
|
||||
"dailyChart": "30天趋势",
|
||||
"periodComparison": "周期对比",
|
||||
"noReferrals": "暂无推荐",
|
||||
"paid": "已付款",
|
||||
"pending": "待处理",
|
||||
"active": "活跃",
|
||||
"referralsCount": "推荐数",
|
||||
"earningsAmount": "收入"
|
||||
},
|
||||
"fields": {
|
||||
"companyName": "公司名称",
|
||||
"companyNamePlaceholder": "您的公司或品牌名称",
|
||||
@@ -591,7 +618,9 @@
|
||||
"description": "描述",
|
||||
"descriptionPlaceholder": "请介绍您的受众以及推广计划...",
|
||||
"expectedReferrals": "预计每月推荐数",
|
||||
"expectedReferralsPlaceholder": "每月预估数量"
|
||||
"expectedReferralsPlaceholder": "每月预估数量",
|
||||
"desiredCommission": "期望佣金比例 (%)",
|
||||
"desiredCommissionPlaceholder": "1 到 100%"
|
||||
}
|
||||
},
|
||||
"withdrawal": {
|
||||
@@ -612,9 +641,9 @@
|
||||
"submitRequest": "提交请求",
|
||||
"requestError": "创建提现请求失败,请重试。",
|
||||
"fields": {
|
||||
"amount": "金额(戈比)",
|
||||
"amountPlaceholder": "输入金额(戈比)",
|
||||
"amountHint": "请输入戈比金额(100戈比 = 1 {{currency}})",
|
||||
"amount": "金额",
|
||||
"amountPlaceholder": "输入金额({{currency}})",
|
||||
"amountHint": "最低:{{min}} {{currency}}",
|
||||
"paymentDetails": "付款信息",
|
||||
"paymentDetailsPlaceholder": "银行卡号、钱包地址或其他付款信息..."
|
||||
},
|
||||
@@ -698,7 +727,8 @@
|
||||
"cannotSpin": "当前无法抽奖",
|
||||
"insufficientBalance": "余额不足",
|
||||
"topUpRequired": "请充值以支付抽奖费用",
|
||||
"starsNotAvailable": "Stars支付不可用。请通过Telegram打开应用。"
|
||||
"starsNotAvailable": "Stars支付不可用。请通过Telegram打开应用。",
|
||||
"noSubscription": "需要有效订阅才能转动轮盘。请先激活订阅。"
|
||||
},
|
||||
"payWithStars": "支付 {stars} Stars",
|
||||
"processingPayment": "处理中...",
|
||||
@@ -835,7 +865,8 @@
|
||||
"roles": "角色",
|
||||
"roleAssign": "角色分配",
|
||||
"policies": "访问策略",
|
||||
"auditLog": "审计日志"
|
||||
"auditLog": "审计日志",
|
||||
"salesStats": "销售统计"
|
||||
},
|
||||
"panel": {
|
||||
"title": "管理面板",
|
||||
@@ -867,7 +898,87 @@
|
||||
"rolesDesc": "管理管理员角色和权限",
|
||||
"roleAssignDesc": "分配和撤销用户角色",
|
||||
"policiesDesc": "配置ABAC访问控制策略",
|
||||
"auditLogDesc": "查看系统活动和访问历史"
|
||||
"auditLogDesc": "查看系统活动和访问历史",
|
||||
"salesStatsDesc": "销售分析与趋势"
|
||||
},
|
||||
"salesStats": {
|
||||
"title": "销售统计",
|
||||
"subtitle": "收入、试用、订阅和续订",
|
||||
"period": {
|
||||
"week": "7天",
|
||||
"month": "30天",
|
||||
"quarter": "90天",
|
||||
"all": "全部",
|
||||
"custom": "自定义"
|
||||
},
|
||||
"tabs": {
|
||||
"trials": "试用",
|
||||
"sales": "销售",
|
||||
"renewals": "续订",
|
||||
"addons": "附加服务",
|
||||
"deposits": "充值"
|
||||
},
|
||||
"summary": {
|
||||
"revenue": "收入",
|
||||
"activeSubs": "活跃订阅",
|
||||
"activeTrials": "活跃试用",
|
||||
"newTrials": "新试用",
|
||||
"conversion": "转化率",
|
||||
"renewals": "续订",
|
||||
"addonRevenue": "附加服务收入"
|
||||
},
|
||||
"trials": {
|
||||
"total": "试用总数",
|
||||
"totalRegistrations": "注册数",
|
||||
"trialsIssued": "已发放试用",
|
||||
"conversion": "转化率",
|
||||
"avgDuration": "平均时长",
|
||||
"byProvider": "按注册来源",
|
||||
"dailyChart": "每日注册与试用",
|
||||
"registrations": "注册"
|
||||
},
|
||||
"sales": {
|
||||
"totalSales": "总销售",
|
||||
"avgOrder": "平均订单",
|
||||
"topTariff": "热门套餐",
|
||||
"byTariff": "按套餐",
|
||||
"byPeriod": "按订阅周期",
|
||||
"dailyChart": "每日销售",
|
||||
"dailyByTariff": "每日按套餐销售",
|
||||
"subscriptions": "订阅"
|
||||
},
|
||||
"renewals": {
|
||||
"total": "续订总数",
|
||||
"rate": "续订率",
|
||||
"revenue": "收入",
|
||||
"comparison": "期间对比",
|
||||
"currentPeriod": "当前期间",
|
||||
"previousPeriod": "上一期间",
|
||||
"dailyChart": "每日续订",
|
||||
"renewals": "续订"
|
||||
},
|
||||
"addons": {
|
||||
"totalPurchases": "购买总数",
|
||||
"totalGb": "总流量",
|
||||
"revenue": "收入",
|
||||
"devicePurchases": "设备购买",
|
||||
"deviceRevenue": "设备收入",
|
||||
"trafficPurchases": "流量",
|
||||
"devicePurchasesDaily": "设备",
|
||||
"byPackage": "按套餐大小",
|
||||
"dailyChart": "每日附加服务购买",
|
||||
"purchases": "购买"
|
||||
},
|
||||
"deposits": {
|
||||
"totalDeposits": "充值总数",
|
||||
"totalAmount": "总金额",
|
||||
"avgDeposit": "平均充值",
|
||||
"byMethod": "按支付方式",
|
||||
"dailyChart": "每日充值",
|
||||
"dailyByMethod": "每日按支付方式充值",
|
||||
"revenue": "收入"
|
||||
},
|
||||
"loadError": "加载统计失败"
|
||||
},
|
||||
"trafficUsage": {
|
||||
"title": "流量使用",
|
||||
@@ -1663,7 +1774,15 @@
|
||||
"users": "用户",
|
||||
"noUsers": "没有注册用户",
|
||||
"paid": "已付费",
|
||||
"hasSub": "有订阅"
|
||||
"hasSub": "有订阅",
|
||||
"dailyChart": "30天趋势",
|
||||
"chartRegistrations": "注册",
|
||||
"chartRevenue": "收入",
|
||||
"totalDeposits": "总充值",
|
||||
"totalSpending": "订阅支出",
|
||||
"periodComparison": "期间比较",
|
||||
"vsLastWeek": "与上周对比",
|
||||
"topRegistrations": "热门注册"
|
||||
},
|
||||
"users": {
|
||||
"title": "活动用户",
|
||||
@@ -1713,7 +1832,8 @@
|
||||
"approved": "已批准",
|
||||
"rejected": "已拒绝",
|
||||
"completed": "已完成",
|
||||
"cancelled": "已取消"
|
||||
"cancelled": "已取消",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"detail": {
|
||||
"title": "提现详情",
|
||||
@@ -1724,6 +1844,7 @@
|
||||
"balance": "余额",
|
||||
"totalReferrals": "总推荐数",
|
||||
"totalEarnings": "总收益",
|
||||
"requestedAmount": "请求金额",
|
||||
"createdAt": "创建时间",
|
||||
"paymentDetails": "付款信息",
|
||||
"noPaymentDetails": "未提供付款信息",
|
||||
@@ -1779,7 +1900,8 @@
|
||||
"website": "网站",
|
||||
"channel": "频道",
|
||||
"description": "描述",
|
||||
"expectedReferrals": "预计每月推荐数"
|
||||
"expectedReferrals": "预计每月推荐数",
|
||||
"desiredCommission": "期望佣金"
|
||||
},
|
||||
"approveDialog": {
|
||||
"title": "批准申请",
|
||||
|
||||
@@ -136,6 +136,14 @@ export default function AdminApplicationReview() {
|
||||
<span className="text-dark-200">{app.expected_monthly_referrals}</span>
|
||||
</div>
|
||||
)}
|
||||
{app.desired_commission_percent != null && (
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<span className="text-dark-500">
|
||||
{t('admin.partners.applicationFields.desiredCommission')}:
|
||||
</span>{' '}
|
||||
<span className="text-dark-200">{app.desired_commission_percent}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import { campaignsApi, CampaignBonusType } from '../api/campaigns';
|
||||
import type { AdminCampaignChartData } from '../api/campaigns';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { DailyChart, PeriodComparison, StatCard } from '../components/stats';
|
||||
import { PARTNER_STATS } from '../constants/partner';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { copyToClipboard } from '../utils/clipboard';
|
||||
import { useHaptic } from '../platform';
|
||||
|
||||
// Icons
|
||||
const CopyIcon = () => (
|
||||
@@ -74,31 +79,14 @@ const bonusTypeConfig: Record<
|
||||
},
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
|
||||
const formatRubles = (kopeks: number): string => {
|
||||
const rubles = kopeks / 100;
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return `${rubles.toLocaleString(locale)} ₽`;
|
||||
};
|
||||
|
||||
const formatDate = (date: string | null): string => {
|
||||
if (!date) return '-';
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return new Date(date).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export default function AdminCampaignStats() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const numericId = id ? Number(id) : null;
|
||||
const isValidId = numericId !== null && !isNaN(numericId);
|
||||
const navigate = useNavigate();
|
||||
const haptic = useHaptic();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const [copiedBot, setCopiedBot] = useState(false);
|
||||
const [copiedWeb, setCopiedWeb] = useState(false);
|
||||
const [showUsers, setShowUsers] = useState(false);
|
||||
@@ -119,42 +107,66 @@ export default function AdminCampaignStats() {
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['campaign-stats', id],
|
||||
queryFn: () => campaignsApi.getCampaignStats(Number(id)),
|
||||
enabled: !!id,
|
||||
queryFn: () => campaignsApi.getCampaignStats(numericId!),
|
||||
enabled: isValidId,
|
||||
staleTime: PARTNER_STATS.STATS_STALE_TIME,
|
||||
});
|
||||
|
||||
// Fetch registrations when users section is open
|
||||
const { data: registrationsData, isLoading: usersLoading } = useQuery({
|
||||
queryKey: ['campaign-registrations', id],
|
||||
queryFn: () => campaignsApi.getCampaignRegistrations(Number(id), 1, 50),
|
||||
enabled: !!id && showUsers,
|
||||
queryFn: () => campaignsApi.getCampaignRegistrations(numericId!, 1, 50),
|
||||
enabled: isValidId && showUsers,
|
||||
});
|
||||
|
||||
const copyBotLink = async () => {
|
||||
if (stats?.deep_link) {
|
||||
// Fetch chart data
|
||||
const { data: chartData, isLoading: chartLoading } = useQuery<AdminCampaignChartData>({
|
||||
queryKey: ['campaign-chart-data', id],
|
||||
queryFn: () => campaignsApi.getChartData(numericId!),
|
||||
enabled: isValidId,
|
||||
staleTime: PARTNER_STATS.STATS_STALE_TIME,
|
||||
});
|
||||
|
||||
const handleCopy = useCallback(
|
||||
async (url: string, type: 'bot' | 'web') => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(stats.deep_link);
|
||||
await copyToClipboard(url);
|
||||
haptic.notification('success');
|
||||
if (type === 'bot') {
|
||||
setCopiedBot(true);
|
||||
clearTimeout(copyBotTimer.current);
|
||||
copyBotTimer.current = setTimeout(() => setCopiedBot(false), 2000);
|
||||
} catch {
|
||||
// Clipboard not available
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyWebLink = async () => {
|
||||
if (stats?.web_link) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(stats.web_link);
|
||||
copyBotTimer.current = setTimeout(
|
||||
() => setCopiedBot(false),
|
||||
PARTNER_STATS.COPY_FEEDBACK_MS,
|
||||
);
|
||||
} else {
|
||||
setCopiedWeb(true);
|
||||
clearTimeout(copyWebTimer.current);
|
||||
copyWebTimer.current = setTimeout(() => setCopiedWeb(false), 2000);
|
||||
copyWebTimer.current = setTimeout(
|
||||
() => setCopiedWeb(false),
|
||||
PARTNER_STATS.COPY_FEEDBACK_MS,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Clipboard not available
|
||||
haptic.notification('error');
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[haptic],
|
||||
);
|
||||
|
||||
const formatDate = useCallback(
|
||||
(date: string | null): string => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString(i18n.language, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
},
|
||||
[i18n.language],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -195,8 +207,8 @@ export default function AdminCampaignStats() {
|
||||
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
|
||||
<ChartIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{stats.name}</h1>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-semibold text-dark-100">{stats.name}</h1>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${bonusTypeConfig[stats.bonus_type].bgColor} ${bonusTypeConfig[stats.bonus_type].color}`}
|
||||
@@ -232,8 +244,8 @@ export default function AdminCampaignStats() {
|
||||
<span className="truncate text-sm text-dark-300">{stats.deep_link}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyBotLink}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-1.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
onClick={() => handleCopy(stats.deep_link!, 'bot')}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-2 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CopyIcon />
|
||||
<span className="text-sm">
|
||||
@@ -256,8 +268,8 @@ export default function AdminCampaignStats() {
|
||||
<span className="truncate text-sm text-dark-300">{stats.web_link}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyWebLink}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-1.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
onClick={() => handleCopy(stats.web_link!, 'web')}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-2 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CopyIcon />
|
||||
<span className="text-sm">
|
||||
@@ -275,21 +287,25 @@ export default function AdminCampaignStats() {
|
||||
{/* Main Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-dark-100">{stats.registrations}</div>
|
||||
<div className="text-xl font-bold text-dark-100 sm:text-2xl">{stats.registrations}</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.registrations')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
{formatRubles(stats.total_revenue_kopeks)}
|
||||
<div className="truncate text-xl font-bold text-success-400 sm:text-2xl">
|
||||
{formatWithCurrency(stats.total_revenue_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.revenue')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">{stats.paid_users_count}</div>
|
||||
<div className="text-xl font-bold text-accent-400 sm:text-2xl">
|
||||
{stats.paid_users_count}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.paidUsers')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">{stats.conversion_rate}%</div>
|
||||
<div className="text-xl font-bold text-accent-400 sm:text-2xl">
|
||||
{stats.conversion_rate}%
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.conversion')}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,7 +322,7 @@ export default function AdminCampaignStats() {
|
||||
</div>
|
||||
{stats.bonus_type === 'balance' && (
|
||||
<div className="text-lg font-medium text-success-400">
|
||||
{formatRubles(stats.balance_issued_kopeks)}
|
||||
{formatWithCurrency(stats.balance_issued_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
</div>
|
||||
)}
|
||||
{stats.bonus_type === 'subscription' && (
|
||||
@@ -330,7 +346,9 @@ export default function AdminCampaignStats() {
|
||||
{t('admin.campaigns.stats.avgRevenuePerUser')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{formatRubles(stats.avg_revenue_per_user_kopeks)}
|
||||
{formatWithCurrency(
|
||||
stats.avg_revenue_per_user_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
@@ -338,7 +356,7 @@ export default function AdminCampaignStats() {
|
||||
{t('admin.campaigns.stats.avgFirstPayment')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{formatRubles(stats.avg_first_payment_kopeks)}
|
||||
{formatWithCurrency(stats.avg_first_payment_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
@@ -371,6 +389,94 @@ export default function AdminCampaignStats() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics Charts */}
|
||||
<div className="space-y-4">
|
||||
{chartLoading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="h-52 animate-pulse rounded-xl bg-dark-800/30" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="h-24 animate-pulse rounded-xl bg-dark-800/30" />
|
||||
<div className="h-24 animate-pulse rounded-xl bg-dark-800/30" />
|
||||
</div>
|
||||
</div>
|
||||
) : chartData ? (
|
||||
<>
|
||||
{/* Deposits vs Spending */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatCard
|
||||
label={t('admin.campaigns.stats.totalDeposits')}
|
||||
value={formatWithCurrency(
|
||||
chartData.total_deposits_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.campaigns.stats.totalSpending')}
|
||||
value={formatWithCurrency(
|
||||
chartData.total_spending_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
)}
|
||||
valueClassName="text-accent-400"
|
||||
/>
|
||||
</div>
|
||||
<DailyChart
|
||||
data={chartData.daily_stats}
|
||||
chartId={`admin-${id}`}
|
||||
title={t('admin.campaigns.stats.dailyChart')}
|
||||
earningsLabel={t('admin.campaigns.stats.chartRevenue')}
|
||||
countLabel={t('admin.campaigns.stats.chartRegistrations')}
|
||||
/>
|
||||
<PeriodComparison
|
||||
data={chartData.period_comparison}
|
||||
title={t('admin.campaigns.stats.periodComparison')}
|
||||
countLabel={t('admin.campaigns.stats.chartRegistrations')}
|
||||
earningsLabel={t('admin.campaigns.stats.chartRevenue')}
|
||||
comparisonLabel={t('admin.campaigns.stats.vsLastWeek')}
|
||||
/>
|
||||
{/* Top Registrations */}
|
||||
{chartData.top_registrations.length > 0 && (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">
|
||||
{t('admin.campaigns.stats.topRegistrations')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{chartData.top_registrations.map((reg) => (
|
||||
<Link
|
||||
key={reg.id}
|
||||
to={`/admin/users/${reg.id}`}
|
||||
className="flex items-center justify-between rounded-xl border border-dark-700/30 bg-dark-800/30 p-3 transition-colors hover:bg-dark-700/50"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate text-sm font-medium text-dark-100">
|
||||
{reg.full_name}
|
||||
</span>
|
||||
{reg.is_active && (
|
||||
<span className="badge-success">
|
||||
{t('admin.campaigns.stats.active')}
|
||||
</span>
|
||||
)}
|
||||
{reg.has_paid && !reg.is_active && (
|
||||
<span className="badge-info">{t('admin.campaigns.stats.paid')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-500">
|
||||
{new Date(reg.created_at).toLocaleDateString(i18n.language)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-success-400">
|
||||
{formatWithCurrency(
|
||||
reg.total_earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Users Section */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800">
|
||||
<button
|
||||
@@ -414,7 +520,9 @@ export default function AdminCampaignStats() {
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{reg.first_name || reg.username || `User #${reg.user_id}`}
|
||||
{reg.first_name ||
|
||||
reg.username ||
|
||||
`${t('admin.campaigns.stats.users')} #${reg.user_id}`}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{reg.telegram_id}</div>
|
||||
</div>
|
||||
|
||||
@@ -414,6 +414,13 @@ export default function AdminPanel() {
|
||||
description: t('admin.panel.trafficUsageDesc'),
|
||||
permission: 'traffic:read',
|
||||
},
|
||||
{
|
||||
to: '/admin/sales-stats',
|
||||
icon: <ChartBarIcon />,
|
||||
title: t('admin.nav.salesStats'),
|
||||
description: t('admin.panel.salesStatsDesc'),
|
||||
permission: 'stats:read',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -232,10 +232,11 @@ export default function AdminPartnerDetail() {
|
||||
{partner.campaigns.map((campaign) => (
|
||||
<div
|
||||
key={campaign.id}
|
||||
className={`flex items-center justify-between rounded-lg bg-dark-700/50 p-3 ${
|
||||
className={`rounded-lg bg-dark-700/50 p-3 ${
|
||||
!campaign.is_active ? 'opacity-60' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-dark-100">{campaign.name}</div>
|
||||
<div className="font-mono text-xs text-dark-500">
|
||||
@@ -274,6 +275,35 @@ export default function AdminPartnerDetail() {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2 border-t border-dark-600/50 pt-2">
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium text-dark-200">
|
||||
{campaign.registrations_count}
|
||||
</div>
|
||||
<div className="text-[10px] text-dark-500">
|
||||
{t('admin.partnerDetail.campaigns.registrations', 'Регистрации')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-sm font-medium text-dark-200">
|
||||
{campaign.referrals_count}
|
||||
</div>
|
||||
<div className="text-[10px] text-dark-500">
|
||||
{t('admin.partnerDetail.campaigns.referrals', 'Рефералы')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={`text-sm font-medium ${campaign.earnings_kopeks > 0 ? 'text-success-400' : 'text-dark-400'}`}
|
||||
>
|
||||
{formatWithCurrency(campaign.earnings_kopeks / 100)}
|
||||
</div>
|
||||
<div className="text-[10px] text-dark-500">
|
||||
{t('admin.partnerDetail.campaigns.earnings', 'Доход')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -242,6 +242,12 @@ export default function AdminPartners() {
|
||||
{app.expected_monthly_referrals}
|
||||
</div>
|
||||
)}
|
||||
{app.desired_commission_percent != null && (
|
||||
<div>
|
||||
{t('admin.partners.applicationFields.desiredCommission')}:{' '}
|
||||
{app.desired_commission_percent}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
|
||||
171
src/pages/AdminSalesStats.tsx
Normal file
171
src/pages/AdminSalesStats.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { SalesStatsParams } from '../api/adminSalesStats';
|
||||
import { salesStatsApi } from '../api/adminSalesStats';
|
||||
import { SALES_STATS } from '../constants/salesStats';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { AdminBackButton } from '../components/admin/AdminBackButton';
|
||||
import { StatCard } from '../components/stats';
|
||||
import {
|
||||
AddonsTab,
|
||||
DepositsTab,
|
||||
PeriodSelector,
|
||||
RenewalsTab,
|
||||
SalesTab,
|
||||
TrialsTab,
|
||||
} from '../components/sales-stats';
|
||||
|
||||
type TabId = 'trials' | 'sales' | 'renewals' | 'addons' | 'deposits';
|
||||
|
||||
export default function AdminSalesStats() {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabId>('trials');
|
||||
const [period, setPeriod] = useState<{
|
||||
days?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}>({ days: SALES_STATS.DEFAULT_PERIOD });
|
||||
|
||||
const params: SalesStatsParams = useMemo(
|
||||
() => ({
|
||||
days: period.days,
|
||||
start_date: period.startDate,
|
||||
end_date: period.endDate,
|
||||
}),
|
||||
[period.days, period.startDate, period.endDate],
|
||||
);
|
||||
|
||||
const isValidPeriod = period.days !== undefined || (!!period.startDate && !!period.endDate);
|
||||
|
||||
const {
|
||||
data: summary,
|
||||
isLoading: summaryLoading,
|
||||
isError: summaryError,
|
||||
} = useQuery({
|
||||
queryKey: ['sales-stats', 'summary', params],
|
||||
queryFn: () => salesStatsApi.getSummary(params),
|
||||
staleTime: SALES_STATS.STALE_TIME,
|
||||
enabled: isValidPeriod,
|
||||
});
|
||||
|
||||
const tabs: { id: TabId; label: string }[] = [
|
||||
{ id: 'trials', label: t('admin.salesStats.tabs.trials') },
|
||||
{ id: 'sales', label: t('admin.salesStats.tabs.sales') },
|
||||
{ id: 'renewals', label: t('admin.salesStats.tabs.renewals') },
|
||||
{ id: 'addons', label: t('admin.salesStats.tabs.addons') },
|
||||
{ id: 'deposits', label: t('admin.salesStats.tabs.deposits') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminBackButton />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100 sm:text-2xl">
|
||||
{t('admin.salesStats.title')}
|
||||
</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.salesStats.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Period selector */}
|
||||
<PeriodSelector value={period} onChange={setPeriod} />
|
||||
|
||||
{/* Summary cards */}
|
||||
{summaryError && (
|
||||
<div className="rounded-xl bg-error-500/10 px-4 py-3 text-sm text-error-400">
|
||||
{t('admin.salesStats.loadError')}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-7">
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.revenue')}
|
||||
value={
|
||||
summaryLoading
|
||||
? '...'
|
||||
: formatWithCurrency(
|
||||
(summary?.total_revenue_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR,
|
||||
)
|
||||
}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.activeSubs')}
|
||||
value={summaryLoading ? '...' : (summary?.active_subscriptions ?? 0)}
|
||||
valueClassName="text-accent-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.activeTrials')}
|
||||
value={summaryLoading ? '...' : (summary?.active_trials ?? 0)}
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.newTrials')}
|
||||
value={summaryLoading ? '...' : (summary?.new_trials ?? 0)}
|
||||
valueClassName="text-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.conversion')}
|
||||
value={summaryLoading ? '...' : `${summary?.trial_to_paid_conversion ?? 0}%`}
|
||||
valueClassName="text-warning-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.renewals')}
|
||||
value={summaryLoading ? '...' : (summary?.renewals_count ?? 0)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.salesStats.summary.addonRevenue')}
|
||||
value={
|
||||
summaryLoading
|
||||
? '...'
|
||||
: formatWithCurrency(
|
||||
(summary?.addon_revenue_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR,
|
||||
)
|
||||
}
|
||||
valueClassName="text-accent-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div
|
||||
className="scrollbar-hide flex gap-1 overflow-x-auto rounded-xl bg-dark-800/30 p-1"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.id}
|
||||
aria-controls={`panel-${tab.id}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`shrink-0 whitespace-nowrap rounded-lg px-3 py-2.5 text-xs font-medium transition-colors sm:text-sm ${
|
||||
activeTab === tab.id
|
||||
? 'bg-dark-700/60 text-dark-100'
|
||||
: 'text-dark-400 hover:text-dark-300'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{isValidPeriod && (
|
||||
<div role="tabpanel" id={`panel-${activeTab}`} aria-labelledby={`tab-${activeTab}`}>
|
||||
{activeTab === 'trials' && <TrialsTab params={params} />}
|
||||
{activeTab === 'sales' && <SalesTab params={params} />}
|
||||
{activeTab === 'renewals' && <RenewalsTab params={params} />}
|
||||
{activeTab === 'addons' && <AddonsTab params={params} />}
|
||||
{activeTab === 'deposits' && <DepositsTab params={params} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -126,6 +126,14 @@ export default function AdminWithdrawalDetail() {
|
||||
<h3 className="mb-4 font-medium text-dark-200">
|
||||
{t('admin.withdrawals.detail.userInfo')}
|
||||
</h3>
|
||||
<div className="mb-3 rounded-lg border border-accent-500/30 bg-accent-500/10 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.withdrawals.detail.requestedAmount')}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-accent-400">
|
||||
{formatWithCurrency(detail.amount_kopeks / 100, 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
|
||||
@@ -35,6 +35,15 @@ export default function Connection() {
|
||||
navigate(-1);
|
||||
}, [navigate]);
|
||||
|
||||
const handleOpenQR = useCallback(() => {
|
||||
navigate('/connection/qr', {
|
||||
state: {
|
||||
url: appConfig?.subscriptionUrl,
|
||||
hideLink: appConfig?.hideLink ?? false,
|
||||
},
|
||||
});
|
||||
}, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
@@ -184,6 +193,7 @@ export default function Connection() {
|
||||
onOpenDeepLink={openDeepLink}
|
||||
isTelegramWebApp={isTelegramWebApp}
|
||||
onGoBack={handleGoBack}
|
||||
onOpenQR={handleOpenQR}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
116
src/pages/ConnectionQR.tsx
Normal file
116
src/pages/ConnectionQR.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useBranding } from '../hooks/useBranding';
|
||||
|
||||
interface ConnectionQRState {
|
||||
url: string;
|
||||
hideLink: boolean;
|
||||
}
|
||||
|
||||
function isValidState(state: unknown): state is ConnectionQRState {
|
||||
if (!state || typeof state !== 'object') return false;
|
||||
const s = state as Record<string, unknown>;
|
||||
return typeof s.url === 'string' && s.url.length > 0 && typeof s.hideLink === 'boolean';
|
||||
}
|
||||
|
||||
export default function ConnectionQR() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const isLoading = useAuthStore((state) => state.isLoading);
|
||||
const { appName } = useBranding();
|
||||
|
||||
const state = location.state as unknown;
|
||||
const validState = isValidState(state) ? state : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, isLoading, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && isAuthenticated && !validState) {
|
||||
navigate('/connection', { replace: true });
|
||||
}
|
||||
}, [isLoading, isAuthenticated, validState, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
navigate(-1);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [navigate]);
|
||||
|
||||
if (isLoading || !validState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex min-h-dvh flex-col items-center justify-center bg-gradient-to-b from-dark-900 to-dark-950">
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute left-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-dark-800/60 backdrop-blur-sm transition-colors hover:bg-dark-700/80"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-200"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex w-full max-w-sm animate-scale-in flex-col items-center px-6">
|
||||
{/* Branding name */}
|
||||
{appName && (
|
||||
<p className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
|
||||
{appName}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="mb-2 text-center text-xl font-bold text-dark-100">
|
||||
{t('subscription.connection.qrTitle')}
|
||||
</h1>
|
||||
|
||||
{/* Hint */}
|
||||
<p className="mb-8 text-center text-sm text-dark-400">
|
||||
{t('subscription.connection.qrScanHint')}
|
||||
</p>
|
||||
|
||||
{/* QR code container */}
|
||||
<div className="rounded-3xl bg-white p-6">
|
||||
<QRCodeSVG
|
||||
value={validState.url}
|
||||
size={280}
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
className="h-[280px] w-[280px] sm:h-[360px] sm:w-[360px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL display */}
|
||||
{!validState.hideLink && (
|
||||
<p className="mt-6 max-w-full truncate text-center font-mono text-xs text-dark-500">
|
||||
{validState.url}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -265,7 +265,7 @@ export default function Dashboard() {
|
||||
balanceRubles={balanceData?.balance_rubles || 0}
|
||||
subscription={subscription}
|
||||
referralCount={referralInfo?.total_referrals || 0}
|
||||
earningsRubles={referralInfo?.total_earnings_rubles || 0}
|
||||
earningsRubles={referralInfo?.available_balance_rubles || 0}
|
||||
refLoading={refLoading}
|
||||
/>
|
||||
|
||||
|
||||
@@ -127,12 +127,13 @@ export default function Login() {
|
||||
const { authorize_url, state } = await authApi.getOAuthAuthorizeUrl(provider);
|
||||
|
||||
// Validate redirect URL — only allow HTTPS to prevent open redirect
|
||||
let parsed: URL;
|
||||
try {
|
||||
const parsed = new URL(authorize_url);
|
||||
if (parsed.protocol !== 'https:') {
|
||||
parsed = new URL(authorize_url);
|
||||
} catch {
|
||||
throw new Error('Invalid OAuth redirect URL');
|
||||
}
|
||||
} catch {
|
||||
if (parsed.protocol !== 'https:') {
|
||||
throw new Error('Invalid OAuth redirect URL');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
@@ -28,6 +28,7 @@ export default function OAuthCallback() {
|
||||
const [error, setError] = useState('');
|
||||
const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth);
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const hasRun = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
@@ -35,9 +36,15 @@ export default function OAuthCallback() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent double-fire from React StrictMode or dependency changes
|
||||
if (hasRun.current) return;
|
||||
hasRun.current = true;
|
||||
|
||||
const authenticate = async () => {
|
||||
const code = searchParams.get('code');
|
||||
const urlState = searchParams.get('state');
|
||||
// VK ID returns device_id in callback URL (required for token exchange)
|
||||
const deviceId = searchParams.get('device_id');
|
||||
|
||||
if (!code || !urlState) {
|
||||
setError(t('auth.oauthError', 'Authorization was denied or failed'));
|
||||
@@ -58,14 +65,13 @@ export default function OAuthCallback() {
|
||||
}
|
||||
|
||||
try {
|
||||
await loginWithOAuth(saved.provider, code, urlState);
|
||||
await loginWithOAuth(saved.provider, code, urlState, deviceId);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } };
|
||||
setError(
|
||||
error.response?.data?.detail ||
|
||||
t('auth.oauthError', 'Authorization was denied or failed'),
|
||||
);
|
||||
const detail =
|
||||
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
|
||||
(err instanceof Error ? err.message : null);
|
||||
setError(detail || t('auth.oauthError', 'Authorization was denied or failed'));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { referralApi } from '../api/referral';
|
||||
import { copyToClipboard } from '../utils/clipboard';
|
||||
import { brandingApi } from '../api/branding';
|
||||
import { partnerApi } from '../api/partners';
|
||||
import { withdrawalApi } from '../api/withdrawals';
|
||||
import { CampaignCard } from '../components/partner/CampaignCard';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
|
||||
const LinkIcon = () => (
|
||||
@@ -88,12 +90,18 @@ function getWithdrawalStatusBadge(status: string): string {
|
||||
}
|
||||
|
||||
export default function Referral() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { formatAmount, currencySymbol, formatPositive, formatWithCurrency } = useCurrency();
|
||||
const queryClient = useQueryClient();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { data: info, isLoading } = useQuery({
|
||||
queryKey: ['referral-info'],
|
||||
@@ -156,11 +164,15 @@ export default function Referral() {
|
||||
},
|
||||
});
|
||||
|
||||
const copyLink = () => {
|
||||
if (referralLink) {
|
||||
navigator.clipboard.writeText(referralLink);
|
||||
const copyLink = async () => {
|
||||
if (!referralLink) return;
|
||||
try {
|
||||
await copyToClipboard(referralLink);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
if (copyTimerRef.current) clearTimeout(copyTimerRef.current);
|
||||
copyTimerRef.current = setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// clipboard write failed silently
|
||||
}
|
||||
};
|
||||
|
||||
@@ -334,10 +346,10 @@ export default function Referral() {
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{ref.first_name || ref.username || `User #${ref.id}`}
|
||||
{ref.first_name || ref.username || t('referral.anonymousUser', { id: ref.id })}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-500">
|
||||
{new Date(ref.created_at).toLocaleDateString()}
|
||||
{new Date(ref.created_at).toLocaleDateString(i18n.language)}
|
||||
</div>
|
||||
</div>
|
||||
{ref.has_paid ? (
|
||||
@@ -384,11 +396,13 @@ export default function Referral() {
|
||||
>
|
||||
<div>
|
||||
<div className="text-dark-100">
|
||||
{earning.referral_first_name || earning.referral_username || 'Referral'}
|
||||
{earning.referral_first_name ||
|
||||
earning.referral_username ||
|
||||
t('referral.anonymousReferral')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-500">
|
||||
{t(`referral.reasons.${earning.reason}`, earning.reason)} •{' '}
|
||||
{new Date(earning.created_at).toLocaleDateString()}
|
||||
{new Date(earning.created_at).toLocaleDateString(i18n.language)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-semibold text-success-400">
|
||||
@@ -442,9 +456,9 @@ export default function Referral() {
|
||||
{partnerStatus?.latest_application?.created_at && (
|
||||
<p className="mt-2 text-xs text-dark-500">
|
||||
{t('referral.partner.submittedAt', {
|
||||
date: new Date(
|
||||
partnerStatus.latest_application.created_at,
|
||||
).toLocaleDateString(),
|
||||
date: new Date(partnerStatus.latest_application.created_at).toLocaleDateString(
|
||||
i18n.language,
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
@@ -535,99 +549,9 @@ export default function Referral() {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{partnerStatus.campaigns.map((campaign) => {
|
||||
const copyLink = (url: string, key: string) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopiedLink(key);
|
||||
setTimeout(() => setCopiedLink(null), 2000);
|
||||
};
|
||||
|
||||
const botKey = `${campaign.id}-bot`;
|
||||
const webKey = `${campaign.id}-web`;
|
||||
|
||||
return (
|
||||
<div key={campaign.id} className="bento-card space-y-4">
|
||||
{/* Campaign header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-dark-100">{campaign.name}</h3>
|
||||
</div>
|
||||
|
||||
{/* Bonus info */}
|
||||
{campaign.bonus_type !== 'none' && (
|
||||
<div className="rounded-lg bg-success-500/10 p-3">
|
||||
<div className="mb-1 text-xs font-medium text-success-500">
|
||||
{t('referral.partner.campaignBonus.title')}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-success-400">
|
||||
{campaign.bonus_type === 'balance' &&
|
||||
t('referral.partner.campaignBonus.balanceDesc', {
|
||||
amount: formatWithCurrency(campaign.balance_bonus_kopeks / 100, 0),
|
||||
})}
|
||||
{campaign.bonus_type === 'subscription' &&
|
||||
t('referral.partner.campaignBonus.subscriptionDesc', {
|
||||
days: campaign.subscription_duration_days ?? 0,
|
||||
...(campaign.subscription_traffic_gb
|
||||
? { traffic: campaign.subscription_traffic_gb }
|
||||
: {}),
|
||||
})}
|
||||
{campaign.bonus_type === 'tariff' &&
|
||||
t('referral.partner.campaignBonus.tariffDesc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bot link */}
|
||||
{campaign.deep_link && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||
{t('referral.partner.campaignLinks.bot')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={campaign.deep_link}
|
||||
className="input flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyLink(campaign.deep_link!, botKey)}
|
||||
className={`btn-primary shrink-0 px-3 py-2 ${
|
||||
copiedLink === botKey ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
}`}
|
||||
>
|
||||
{copiedLink === botKey ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Web link */}
|
||||
{campaign.web_link && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||
{t('referral.partner.campaignLinks.web')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={campaign.web_link}
|
||||
className="input flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyLink(campaign.web_link!, webKey)}
|
||||
className={`btn-primary shrink-0 px-3 py-2 ${
|
||||
copiedLink === webKey ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
}`}
|
||||
>
|
||||
{copiedLink === webKey ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{partnerStatus.campaigns.map((campaign) => (
|
||||
<CampaignCard key={campaign.id} campaign={campaign} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -731,7 +655,7 @@ export default function Referral() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-500">
|
||||
{new Date(item.created_at).toLocaleDateString()}
|
||||
{new Date(item.created_at).toLocaleDateString(i18n.language)}
|
||||
{item.payment_details && (
|
||||
<span className="ml-1">
|
||||
•{' '}
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function ReferralPartnerApply() {
|
||||
telegram_channel: '',
|
||||
description: '',
|
||||
expected_monthly_referrals: undefined,
|
||||
desired_commission_percent: undefined,
|
||||
});
|
||||
|
||||
// Guard: redirect if already approved or pending
|
||||
@@ -50,6 +51,9 @@ export default function ReferralPartnerApply() {
|
||||
if (form.expected_monthly_referrals) {
|
||||
payload.expected_monthly_referrals = form.expected_monthly_referrals;
|
||||
}
|
||||
if (form.desired_commission_percent) {
|
||||
payload.desired_commission_percent = form.desired_commission_percent;
|
||||
}
|
||||
applyMutation.mutate(payload);
|
||||
};
|
||||
|
||||
@@ -126,6 +130,25 @@ export default function ReferralPartnerApply() {
|
||||
placeholder={t('referral.partner.fields.expectedReferralsPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
||||
{t('referral.partner.fields.desiredCommission')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
className="input w-full"
|
||||
value={form.desired_commission_percent ?? ''}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
desired_commission_percent: e.target.value ? Number(e.target.value) : undefined,
|
||||
})
|
||||
}
|
||||
placeholder={t('referral.partner.fields.desiredCommissionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{applyMutation.isError && (
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function ReferralWithdrawalRequest() {
|
||||
const { formatWithCurrency, currencySymbol } = useCurrency();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
amount_kopeks: 0,
|
||||
amount_rubles: 0,
|
||||
payment_details: '',
|
||||
});
|
||||
|
||||
@@ -40,9 +40,9 @@ export default function ReferralWithdrawalRequest() {
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (form.payment_details.length < 5) return;
|
||||
if (form.amount_kopeks <= 0) return;
|
||||
if (form.amount_rubles <= 0) return;
|
||||
withdrawMutation.mutate({
|
||||
amount_kopeks: form.amount_kopeks,
|
||||
amount_kopeks: Math.round(form.amount_rubles * 100),
|
||||
payment_details: form.payment_details,
|
||||
});
|
||||
};
|
||||
@@ -64,21 +64,25 @@ export default function ReferralWithdrawalRequest() {
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={balance?.min_amount_kopeks ?? 0}
|
||||
max={balance?.available_total ?? 0}
|
||||
step={100}
|
||||
min={balance ? Math.ceil(balance.min_amount_kopeks / 100) : 0}
|
||||
max={balance ? Math.floor(balance.available_total / 100) : 0}
|
||||
className="input w-full"
|
||||
value={form.amount_kopeks || ''}
|
||||
value={form.amount_rubles || ''}
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
amount_kopeks: e.target.value ? Number(e.target.value) : 0,
|
||||
amount_rubles: e.target.value ? Number(e.target.value) : 0,
|
||||
})
|
||||
}
|
||||
placeholder={t('referral.withdrawal.fields.amountPlaceholder')}
|
||||
placeholder={t('referral.withdrawal.fields.amountPlaceholder', {
|
||||
currency: currencySymbol,
|
||||
})}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('referral.withdrawal.fields.amountHint', { currency: currencySymbol })}
|
||||
{t('referral.withdrawal.fields.amountHint', {
|
||||
min: balance ? Math.ceil(balance.min_amount_kopeks / 100) : 0,
|
||||
currency: currencySymbol,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -115,12 +119,12 @@ export default function ReferralWithdrawalRequest() {
|
||||
disabled={
|
||||
withdrawMutation.isPending ||
|
||||
form.payment_details.length < 5 ||
|
||||
form.amount_kopeks <= 0
|
||||
form.amount_rubles <= 0
|
||||
}
|
||||
className={`btn-primary flex-1 px-5 ${
|
||||
withdrawMutation.isPending ||
|
||||
form.payment_details.length < 5 ||
|
||||
form.amount_kopeks <= 0
|
||||
form.amount_rubles <= 0
|
||||
? 'opacity-50'
|
||||
: ''
|
||||
}`}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1903
src/pages/SubscriptionPurchase.tsx
Normal file
1903
src/pages/SubscriptionPurchase.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -403,6 +403,7 @@ export default function Wheel() {
|
||||
};
|
||||
|
||||
const handleUnifiedSpin = () => {
|
||||
if (noSubscription) return;
|
||||
if (paymentType === 'telegram_stars') {
|
||||
if (!config?.spin_cost_stars_enabled || !config?.spin_cost_stars) {
|
||||
notify.warning(t('wheel.starsNotAvailable'));
|
||||
@@ -505,10 +506,12 @@ export default function Wheel() {
|
||||
|
||||
// Stars via Telegram invoice don't require ruble balance, so only check daily limit
|
||||
const dailyLimitReached = config.daily_limit > 0 && config.user_spins_today >= config.daily_limit;
|
||||
const noSubscription = !config.has_subscription;
|
||||
const spinDisabled =
|
||||
isSpinning ||
|
||||
isPayingStars ||
|
||||
dailyLimitReached ||
|
||||
noSubscription ||
|
||||
(paymentType === 'telegram_stars' ? !starsEnabled : !config.can_spin);
|
||||
|
||||
return (
|
||||
@@ -614,8 +617,17 @@ export default function Wheel() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* No subscription hint */}
|
||||
{!isSpinning && noSubscription && (
|
||||
<div className="rounded-linear border border-warning-500/30 bg-warning-500/5 p-4 text-center">
|
||||
<p className="text-warning-400">{t('wheel.errors.noSubscription')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Cannot spin hint — only show for days payment (Stars via invoice always works) */}
|
||||
{!isSpinning && paymentType !== 'telegram_stars' && !config.can_spin && (
|
||||
{!isSpinning &&
|
||||
!noSubscription &&
|
||||
paymentType !== 'telegram_stars' &&
|
||||
!config.can_spin && (
|
||||
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
|
||||
<p className="text-dark-400">
|
||||
{config.can_spin_reason === 'daily_limit_reached'
|
||||
@@ -625,7 +637,10 @@ export default function Wheel() {
|
||||
</div>
|
||||
)}
|
||||
{/* Daily limit hint for Stars payment (not covered by can_spin check) */}
|
||||
{!isSpinning && paymentType === 'telegram_stars' && dailyLimitReached && (
|
||||
{!isSpinning &&
|
||||
!noSubscription &&
|
||||
paymentType === 'telegram_stars' &&
|
||||
dailyLimitReached && (
|
||||
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
|
||||
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,12 @@ interface AuthState {
|
||||
loginWithTelegram: (initData: string) => Promise<void>;
|
||||
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
|
||||
loginWithEmail: (email: string, password: string) => Promise<void>;
|
||||
loginWithOAuth: (provider: string, code: string, state: string) => Promise<void>;
|
||||
loginWithOAuth: (
|
||||
provider: string,
|
||||
code: string,
|
||||
state: string,
|
||||
deviceId?: string | null,
|
||||
) => Promise<void>;
|
||||
registerWithEmail: (
|
||||
email: string,
|
||||
password: string,
|
||||
@@ -295,13 +300,14 @@ export const useAuthStore = create<AuthState>()(
|
||||
await get().checkAdminStatus();
|
||||
},
|
||||
|
||||
loginWithOAuth: async (provider, code, state) => {
|
||||
loginWithOAuth: async (provider, code, state, deviceId) => {
|
||||
const campaignSlug = consumeCampaignSlug();
|
||||
const referralCode = consumeReferralCode();
|
||||
const response = await authApi.oauthCallback(
|
||||
provider,
|
||||
code,
|
||||
state,
|
||||
deviceId,
|
||||
campaignSlug,
|
||||
referralCode,
|
||||
);
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
animation: border-rotate var(--border-duration, 3s) linear infinite;
|
||||
}
|
||||
|
||||
.hover-border-gradient {
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.hover-border-gradient:hover,
|
||||
.hover-border-gradient:active {
|
||||
box-shadow: 0 0 20px var(--accent-glow, rgba(var(--color-accent-400), 0.25));
|
||||
@@ -251,9 +255,11 @@
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
background-size: 200px 200px;
|
||||
background-repeat: repeat;
|
||||
opacity: 0.03;
|
||||
}
|
||||
|
||||
@@ -264,10 +270,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Optimize main scrollable content */
|
||||
/* Main content — containment removed to avoid GPU layer conflicts
|
||||
with isolation: isolate on content wrapper */
|
||||
main {
|
||||
/* Prevent layout shifts during scroll */
|
||||
contain: layout style;
|
||||
contain: content;
|
||||
}
|
||||
|
||||
/* Light theme - Champagne */
|
||||
@@ -345,7 +351,6 @@ img.twemoji {
|
||||
@apply border border-dark-700/40 bg-dark-900/70;
|
||||
border-radius: var(--bento-radius);
|
||||
padding: var(--bento-padding);
|
||||
transform: translateZ(0);
|
||||
/* Glass Border - Inset Highlight */
|
||||
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||
/* Stagger animation support */
|
||||
@@ -357,7 +362,7 @@ img.twemoji {
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.bento-card {
|
||||
@apply bg-dark-900/50 backdrop-blur-sm;
|
||||
@apply bg-dark-900/60;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,12 +375,16 @@ img.twemoji {
|
||||
|
||||
.bento-card-hover {
|
||||
@apply bento-card cursor-pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition:
|
||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
background-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.bento-card-hover:hover {
|
||||
@apply border-dark-600/50 bg-dark-800/60 shadow-lg;
|
||||
transform: translateY(-4px);
|
||||
transform: translateY(-2px) translateZ(0);
|
||||
/* Intensify Glass Border & Add Top Spotlight */
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.1),
|
||||
@@ -399,22 +408,25 @@ img.twemoji {
|
||||
}
|
||||
|
||||
.bento-card-hover:active {
|
||||
transform: scale(0.98);
|
||||
transform: scale(0.98) translateZ(0);
|
||||
transition-duration: 0.15s;
|
||||
}
|
||||
|
||||
.bento-card-glow {
|
||||
@apply bento-card cursor-pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition:
|
||||
border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.bento-card-glow:hover {
|
||||
@apply border-accent-500/30 shadow-glow;
|
||||
transform: translateY(-4px);
|
||||
transform: translateY(-2px) translateZ(0);
|
||||
}
|
||||
|
||||
.bento-card-glow:active {
|
||||
transform: scale(0.98);
|
||||
transform: scale(0.98) translateZ(0);
|
||||
transition-duration: 0.15s;
|
||||
}
|
||||
|
||||
@@ -445,13 +457,13 @@ img.twemoji {
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.light .bento-card {
|
||||
@apply bg-champagne-50/80 backdrop-blur-sm;
|
||||
@apply bg-champagne-50/85;
|
||||
}
|
||||
}
|
||||
|
||||
.light .bento-card-hover:hover {
|
||||
@apply border-champagne-400/50 bg-champagne-50 shadow-md;
|
||||
transform: translateY(-4px);
|
||||
transform: translateY(-2px) translateZ(0);
|
||||
/* Intensify Light Theme Glass Border */
|
||||
box-shadow:
|
||||
inset 0 1px 0 0 rgba(0, 0, 0, 0.06),
|
||||
@@ -465,23 +477,21 @@ img.twemoji {
|
||||
|
||||
.light .bento-card-glow:hover {
|
||||
@apply border-accent-400/40 shadow-lg;
|
||||
transform: translateY(-4px);
|
||||
transform: translateY(-2px) translateZ(0);
|
||||
}
|
||||
|
||||
/* ========== DARK THEME COMPONENTS (default) ========== */
|
||||
|
||||
/* Cards - Dark (unified style) */
|
||||
.card {
|
||||
@apply border border-dark-700/40 bg-dark-900/70 p-5 transition-all duration-200 sm:p-6;
|
||||
@apply border border-dark-700/40 bg-dark-900/70 p-5 transition-colors duration-200 sm:p-6;
|
||||
border-radius: var(--bento-radius);
|
||||
transform: translateZ(0);
|
||||
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Enable backdrop-blur only on desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.card {
|
||||
@apply bg-dark-900/50 backdrop-blur-sm;
|
||||
@apply bg-dark-900/60;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,7 +510,6 @@ img.twemoji {
|
||||
/* Glass effect - Dark (optimized for mobile) */
|
||||
.glass {
|
||||
@apply border border-dark-700/30 bg-dark-900/80;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
/* Enable backdrop-blur only on desktop where it's performant */
|
||||
@@ -512,7 +521,8 @@ img.twemoji {
|
||||
|
||||
/* Buttons - Dark */
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all duration-200 ease-smooth focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-900 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50;
|
||||
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 text-sm font-medium duration-200 ease-smooth focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-dark-900 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50;
|
||||
transition-property: color, background-color, border-color, box-shadow, transform, opacity;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@@ -532,7 +542,8 @@ img.twemoji {
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
@apply rounded-lg p-2 text-dark-400 transition-all duration-200 hover:bg-dark-800 hover:text-dark-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 active:scale-95;
|
||||
@apply rounded-lg p-2 text-dark-400 duration-200 hover:bg-dark-800 hover:text-dark-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent-500/50 active:scale-95;
|
||||
transition-property: color, background-color, transform;
|
||||
}
|
||||
|
||||
/* Highlighted button for onboarding */
|
||||
@@ -542,7 +553,8 @@ img.twemoji {
|
||||
|
||||
/* Inputs - Dark */
|
||||
.input {
|
||||
@apply w-full rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-100 placeholder-dark-500 transition-all duration-200 focus:border-accent-500/50 focus:outline-none focus:ring-2 focus:ring-accent-500/20;
|
||||
@apply w-full rounded-xl border border-dark-700/50 bg-dark-800/50 px-4 py-3 text-sm text-dark-100 placeholder-dark-500 duration-200 focus:border-accent-500/50 focus:outline-none focus:ring-2 focus:ring-accent-500/20;
|
||||
transition-property: color, background-color, border-color, box-shadow;
|
||||
}
|
||||
|
||||
.input-error {
|
||||
@@ -605,7 +617,8 @@ img.twemoji {
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
@apply h-full rounded-full transition-all duration-500 ease-smooth;
|
||||
@apply h-full rounded-full duration-500 ease-smooth;
|
||||
transition-property: width, background-color;
|
||||
}
|
||||
|
||||
/* Stat card - Dark */
|
||||
@@ -619,7 +632,8 @@ img.twemoji {
|
||||
|
||||
/* Navigation - Dark */
|
||||
.nav-item {
|
||||
@apply flex items-center gap-3 rounded-xl px-4 py-3 text-dark-400 transition-all duration-200 hover:bg-dark-800/50 hover:text-dark-100;
|
||||
@apply flex items-center gap-3 rounded-xl px-4 py-3 text-dark-400 duration-200 hover:bg-dark-800/50 hover:text-dark-100;
|
||||
transition-property: color, background-color;
|
||||
}
|
||||
|
||||
.nav-item-active {
|
||||
@@ -646,7 +660,8 @@ img.twemoji {
|
||||
}
|
||||
|
||||
.bottom-nav-item {
|
||||
@apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl px-3 py-2.5 text-dark-500 transition-all duration-200;
|
||||
@apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl px-3 py-2.5 text-dark-500 duration-200;
|
||||
transition-property: color, background-color;
|
||||
}
|
||||
|
||||
.bottom-nav-item:hover {
|
||||
@@ -654,7 +669,8 @@ img.twemoji {
|
||||
}
|
||||
|
||||
.bottom-nav-item-active {
|
||||
@apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl bg-accent-500/15 px-3 py-2.5 text-accent-400 transition-all duration-200;
|
||||
@apply flex min-w-[56px] flex-1 shrink-0 flex-col items-center justify-center rounded-2xl bg-accent-500/15 px-3 py-2.5 text-accent-400 duration-200;
|
||||
transition-property: color, background-color;
|
||||
}
|
||||
|
||||
/* Divider - Dark */
|
||||
@@ -671,7 +687,8 @@ img.twemoji {
|
||||
.backdrop-blur-lg,
|
||||
.backdrop-blur-xl,
|
||||
.backdrop-blur-2xl,
|
||||
.backdrop-blur-3xl {
|
||||
.backdrop-blur-3xl,
|
||||
.backdrop-blur-linear {
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
@@ -683,13 +700,12 @@ img.twemoji {
|
||||
.light .card {
|
||||
@apply border-champagne-300/50 bg-champagne-50/90 p-5 shadow-sm sm:p-6;
|
||||
border-radius: var(--bento-radius);
|
||||
transform: translateZ(0);
|
||||
box-shadow: inset 0 1px 0 0 rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.light .card {
|
||||
@apply bg-champagne-50/80 backdrop-blur-sm;
|
||||
@apply bg-champagne-50/85;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -704,7 +720,6 @@ img.twemoji {
|
||||
/* Glass effect - Light (optimized for mobile) */
|
||||
.light .glass {
|
||||
@apply border border-champagne-300/40 bg-champagne-50/90;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@@ -1098,7 +1113,7 @@ img.twemoji {
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1136,7 +1151,7 @@ img.twemoji {
|
||||
.btn:active,
|
||||
.card-hover:active,
|
||||
.nav-item:active {
|
||||
transform: scale(0.98);
|
||||
transform: scale(0.98) translateZ(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1245,7 +1260,9 @@ input[type='checkbox'] {
|
||||
border-radius: 0.375rem;
|
||||
background-color: rgb(var(--color-dark-700));
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -423,6 +423,9 @@ export interface ReferralInfo {
|
||||
total_earnings_kopeks: number;
|
||||
total_earnings_rubles: number;
|
||||
commission_percent: number;
|
||||
available_balance_kopeks: number;
|
||||
available_balance_rubles: number;
|
||||
withdrawn_kopeks: number;
|
||||
}
|
||||
|
||||
export interface ReferralTerms {
|
||||
|
||||
24
src/utils/clipboard.ts
Normal file
24
src/utils/clipboard.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Cross-platform clipboard utility with fallback for Telegram WebView.
|
||||
* navigator.clipboard.writeText() is unavailable in some WebViews
|
||||
* (Android System WebView, unfocused tabs, insecure contexts).
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
try {
|
||||
textarea.select();
|
||||
const ok = document.execCommand('copy');
|
||||
if (!ok) throw new Error('execCommand copy failed');
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,8 @@ export function getGlassColors(isDark: boolean) {
|
||||
// Card container
|
||||
cardBg: isDark
|
||||
? 'linear-gradient(145deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%)'
|
||||
: 'linear-gradient(145deg, rgba(255,255,255,0.85) 0%, rgba(255,255,255,0.6) 100%)',
|
||||
cardBorder: isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.08)',
|
||||
: 'linear-gradient(145deg, rgba(255,255,255,0.95) 0%, rgba(255,255,255,0.88) 100%)',
|
||||
cardBorder: isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.1)',
|
||||
|
||||
// Inner sections (cards within cards)
|
||||
innerBg: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)',
|
||||
@@ -38,6 +38,6 @@ export function getGlassColors(isDark: boolean) {
|
||||
glowAlpha: isDark ? '15' : '08',
|
||||
|
||||
// Shadows for light mode depth
|
||||
shadow: isDark ? 'none' : '0 2px 12px rgba(0,0,0,0.06)',
|
||||
shadow: isDark ? 'none' : '0 2px 16px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.03)',
|
||||
};
|
||||
}
|
||||
|
||||
42
src/utils/subscriptionHelpers.ts
Normal file
42
src/utils/subscriptionHelpers.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import i18n from '../i18n';
|
||||
|
||||
export type PurchaseStep = 'period' | 'traffic' | 'servers' | 'devices' | 'confirm';
|
||||
|
||||
export const getErrorMessage = (error: unknown): string => {
|
||||
if (error instanceof AxiosError) {
|
||||
const detail = error.response?.data?.detail;
|
||||
if (typeof detail === 'string') return detail;
|
||||
if (typeof detail === 'object' && detail?.message) return detail.message;
|
||||
}
|
||||
if (error instanceof Error) return error.message;
|
||||
return i18n.t('common.error');
|
||||
};
|
||||
|
||||
export const getInsufficientBalanceError = (
|
||||
error: unknown,
|
||||
): { required: number; balance: number; missingAmount?: number } | null => {
|
||||
if (error instanceof AxiosError) {
|
||||
const detail = error.response?.data?.detail;
|
||||
if (
|
||||
typeof detail === 'object' &&
|
||||
(detail?.code === 'insufficient_balance' || detail?.code === 'insufficient_funds')
|
||||
) {
|
||||
return {
|
||||
required: detail.required || detail.total_price || 0,
|
||||
balance: detail.balance || 0,
|
||||
missingAmount: detail.missing_amount || detail.missingAmount || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getFlagEmoji = (countryCode: string): string => {
|
||||
if (!countryCode || countryCode.length !== 2) return '';
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map((char) => 127397 + char.charCodeAt(0));
|
||||
return String.fromCodePoint(...codePoints);
|
||||
};
|
||||
12
src/vite-env.d.ts
vendored
12
src/vite-env.d.ts
vendored
@@ -12,3 +12,15 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
/** Telegram WebApp global — available when running inside Telegram Mini App */
|
||||
interface TelegramWebAppGlobal {
|
||||
onEvent?: (event: string, callback: () => void) => void;
|
||||
offEvent?: (event: string, callback: () => void) => void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
Telegram?: {
|
||||
WebApp?: TelegramWebAppGlobal;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user