Merge pull request #243 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-03-02 22:26:41 +03:00
committed by GitHub
93 changed files with 7734 additions and 3064 deletions

394
package-lock.json generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -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={

View File

@@ -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
View 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;
},
};

View File

@@ -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,
},

View File

@@ -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;
},
};

View File

@@ -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');

View File

@@ -17,6 +17,7 @@ interface ReferralEarning {
reason: string;
referral_username: string | null;
referral_first_name: string | null;
campaign_name: string | null;
created_at: string;
}

View File

@@ -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 {

View File

@@ -156,7 +156,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
};
const handleUseNow = () => {
navigate('/subscription', { state: { scrollToExtend: true } });
navigate('/subscription/purchase');
};
const handleDeactivateClick = () => {

View File

@@ -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

View File

@@ -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,
);
}

View File

@@ -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 && (

View File

@@ -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) => (

View File

@@ -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>
)}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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)]',
],

View File

@@ -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)}

View File

@@ -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" />

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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';

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;

View 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';

View 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;
}

View 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>
);
}

View File

@@ -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 = `

View File

@@ -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>

View File

@@ -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}

View File

@@ -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"

View File

@@ -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>
);
});

View File

@@ -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"

View File

@@ -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" />;
}

View File

@@ -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" />;
}

View File

@@ -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);

View File

@@ -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`,

View File

@@ -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" />;
}

View File

@@ -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" />;
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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" />;

View File

@@ -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
View 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
View 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;

View 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;

View 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;
}

View 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;
}

View File

@@ -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",

View File

@@ -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": "تأیید درخواست",

View File

@@ -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": "Одобрить заявку",

View File

@@ -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": "批准申请",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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',
},
],
},
{

View File

@@ -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>
)}

View File

@@ -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 */}

View 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>
);
}

View File

@@ -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">

View File

@@ -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
View 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>
);
}

View File

@@ -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}
/>

View File

@@ -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');
}

View File

@@ -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'));
}
};

View File

@@ -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">
&bull;{' '}

View File

@@ -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 && (

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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,
);

View File

@@ -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;
}

View File

@@ -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
View 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);
}
}
}

View File

@@ -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)',
};
}

View 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
View File

@@ -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;
};
}