mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
394
package-lock.json
generated
394
package-lock.json
generated
@@ -36,11 +36,13 @@
|
|||||||
"i18next": "^25.8.4",
|
"i18next": "^25.8.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"jsencrypt": "^3.5.4",
|
"jsencrypt": "^3.5.4",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-i18next": "^16.5.4",
|
"react-i18next": "^16.5.4",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
"react-twemoji": "^0.7.2",
|
"react-twemoji": "^0.7.2",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"simplex-noise": "^4.0.3",
|
"simplex-noise": "^4.0.3",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
@@ -2244,6 +2246,42 @@
|
|||||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.2",
|
"version": "1.0.0-rc.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
||||||
@@ -2601,6 +2639,18 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.90.20",
|
"version": "5.90.20",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||||
@@ -2826,6 +2876,69 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@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": {
|
"node_modules/@types/dompurify": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||||
@@ -2897,6 +3010,12 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.54.0",
|
"version": "8.54.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",
|
||||||
@@ -3756,6 +3875,127 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"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": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -3919,6 +4165,16 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||||
@@ -4187,7 +4443,6 @@
|
|||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
@@ -4692,6 +4947,16 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||||
@@ -4719,6 +4984,15 @@
|
|||||||
"node": ">=0.8.19"
|
"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": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
@@ -5673,6 +5947,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.18.0",
|
"version": "0.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||||
@@ -5883,6 +6196,57 @@
|
|||||||
"node": ">=8.10.0"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -6307,6 +6671,12 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"version": "7.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||||
|
|||||||
@@ -44,11 +44,13 @@
|
|||||||
"i18next": "^25.8.4",
|
"i18next": "^25.8.4",
|
||||||
"i18next-browser-languagedetector": "^8.2.0",
|
"i18next-browser-languagedetector": "^8.2.0",
|
||||||
"jsencrypt": "^3.5.4",
|
"jsencrypt": "^3.5.4",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-i18next": "^16.5.4",
|
"react-i18next": "^16.5.4",
|
||||||
"react-router": "^7.13.0",
|
"react-router": "^7.13.0",
|
||||||
"react-twemoji": "^0.7.2",
|
"react-twemoji": "^0.7.2",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"simplex-noise": "^4.0.3",
|
"simplex-noise": "^4.0.3",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
|
|||||||
31
src/App.tsx
31
src/App.tsx
@@ -26,6 +26,7 @@ import Dashboard from './pages/Dashboard';
|
|||||||
|
|
||||||
// User pages - lazy load
|
// User pages - lazy load
|
||||||
const Subscription = lazy(() => import('./pages/Subscription'));
|
const Subscription = lazy(() => import('./pages/Subscription'));
|
||||||
|
const SubscriptionPurchase = lazy(() => import('./pages/SubscriptionPurchase'));
|
||||||
const Balance = lazy(() => import('./pages/Balance'));
|
const Balance = lazy(() => import('./pages/Balance'));
|
||||||
const Referral = lazy(() => import('./pages/Referral'));
|
const Referral = lazy(() => import('./pages/Referral'));
|
||||||
const Support = lazy(() => import('./pages/Support'));
|
const Support = lazy(() => import('./pages/Support'));
|
||||||
@@ -35,6 +36,7 @@ const Polls = lazy(() => import('./pages/Polls'));
|
|||||||
const Info = lazy(() => import('./pages/Info'));
|
const Info = lazy(() => import('./pages/Info'));
|
||||||
const Wheel = lazy(() => import('./pages/Wheel'));
|
const Wheel = lazy(() => import('./pages/Wheel'));
|
||||||
const Connection = lazy(() => import('./pages/Connection'));
|
const Connection = lazy(() => import('./pages/Connection'));
|
||||||
|
const ConnectionQR = lazy(() => import('./pages/ConnectionQR'));
|
||||||
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
|
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
|
||||||
const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
|
const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'));
|
|||||||
const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail'));
|
const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail'));
|
||||||
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
|
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
|
||||||
const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage'));
|
const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage'));
|
||||||
|
const AdminSalesStats = lazy(() => import('./pages/AdminSalesStats'));
|
||||||
const AdminUpdates = lazy(() => import('./pages/AdminUpdates'));
|
const AdminUpdates = lazy(() => import('./pages/AdminUpdates'));
|
||||||
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail'));
|
||||||
const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail'));
|
const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail'));
|
||||||
@@ -202,6 +205,16 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/subscription/purchase"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<SubscriptionPurchase />
|
||||||
|
</LazyPage>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/balance"
|
path="/balance"
|
||||||
element={
|
element={
|
||||||
@@ -322,6 +335,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/connection/qr"
|
||||||
|
element={
|
||||||
|
<LazyPage>
|
||||||
|
<ConnectionQR />
|
||||||
|
</LazyPage>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/connection"
|
path="/connection"
|
||||||
element={
|
element={
|
||||||
@@ -724,6 +745,16 @@ function App() {
|
|||||||
</PermissionRoute>
|
</PermissionRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/sales-stats"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="stats:read">
|
||||||
|
<LazyPage>
|
||||||
|
<AdminSalesStats />
|
||||||
|
</LazyPage>
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/admin/payment-methods"
|
path="/admin/payment-methods"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -243,8 +243,9 @@ export interface DashboardStats {
|
|||||||
|
|
||||||
export interface TopReferrerItem {
|
export interface TopReferrerItem {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
telegram_id: number;
|
telegram_id: number | null;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
email?: string | null;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
invited_count: number;
|
invited_count: number;
|
||||||
invited_today: number;
|
invited_today: number;
|
||||||
@@ -288,15 +289,16 @@ export interface TopCampaignsResponse {
|
|||||||
export interface RecentPaymentItem {
|
export interface RecentPaymentItem {
|
||||||
id: number;
|
id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
telegram_id: number;
|
telegram_id: number | null;
|
||||||
username?: string;
|
email?: string | null;
|
||||||
|
username?: string | null;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
amount_kopeks: number;
|
amount_kopeks: number;
|
||||||
amount_rubles: number;
|
amount_rubles: number;
|
||||||
type: string;
|
type: string;
|
||||||
type_display: string;
|
type_display: string;
|
||||||
payment_method?: string;
|
payment_method?: string | null;
|
||||||
description?: string;
|
description?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
is_completed: boolean;
|
is_completed: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
199
src/api/adminSalesStats.ts
Normal file
199
src/api/adminSalesStats.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import apiClient from './client';
|
||||||
|
|
||||||
|
// ============ Period Params ============
|
||||||
|
|
||||||
|
export interface SalesStatsParams {
|
||||||
|
days?: number;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Summary ============
|
||||||
|
|
||||||
|
export interface SalesSummary {
|
||||||
|
total_revenue_kopeks: number;
|
||||||
|
active_subscriptions: number;
|
||||||
|
active_trials: number;
|
||||||
|
new_trials: number;
|
||||||
|
trial_to_paid_conversion: number;
|
||||||
|
renewals_count: number;
|
||||||
|
addon_revenue_kopeks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Trials ============
|
||||||
|
|
||||||
|
export interface ProviderBreakdownItem {
|
||||||
|
provider: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyTrialItem {
|
||||||
|
date: string;
|
||||||
|
registrations: number;
|
||||||
|
trials: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrialsStats {
|
||||||
|
total_trials: number;
|
||||||
|
total_registrations: number;
|
||||||
|
conversion_rate: number;
|
||||||
|
avg_trial_duration_days: number;
|
||||||
|
by_provider: ProviderBreakdownItem[];
|
||||||
|
daily: DailyTrialItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Sales ============
|
||||||
|
|
||||||
|
export interface SalesByTariffItem {
|
||||||
|
tariff_id: number;
|
||||||
|
tariff_name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesByPeriodItem {
|
||||||
|
period_days: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailySalesItem {
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
revenue_kopeks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyTariffSalesItem {
|
||||||
|
date: string;
|
||||||
|
tariff_name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesStats {
|
||||||
|
total_sales: number;
|
||||||
|
total_revenue_kopeks: number;
|
||||||
|
avg_order_kopeks: number;
|
||||||
|
top_tariff_name: string;
|
||||||
|
by_tariff: SalesByTariffItem[];
|
||||||
|
by_period: SalesByPeriodItem[];
|
||||||
|
daily: DailySalesItem[];
|
||||||
|
daily_by_tariff: DailyTariffSalesItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Renewals ============
|
||||||
|
|
||||||
|
export interface RenewalPeriodStats {
|
||||||
|
count: number;
|
||||||
|
revenue_kopeks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenewalChange {
|
||||||
|
absolute: number;
|
||||||
|
percent: number;
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyRenewalItem {
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenewalsStats {
|
||||||
|
total_renewals: number;
|
||||||
|
total_revenue_kopeks: number;
|
||||||
|
renewal_rate: number;
|
||||||
|
current_period: RenewalPeriodStats;
|
||||||
|
previous_period: RenewalPeriodStats;
|
||||||
|
change: RenewalChange;
|
||||||
|
daily: DailyRenewalItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Add-ons ============
|
||||||
|
|
||||||
|
export interface AddonByPackageItem {
|
||||||
|
traffic_gb: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyAddonItem {
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
total_gb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyDeviceItem {
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddonsStats {
|
||||||
|
total_purchases: number;
|
||||||
|
total_gb_purchased: number;
|
||||||
|
addon_revenue_kopeks: number;
|
||||||
|
device_purchases: number;
|
||||||
|
device_revenue_kopeks: number;
|
||||||
|
by_package: AddonByPackageItem[];
|
||||||
|
daily: DailyAddonItem[];
|
||||||
|
daily_devices: DailyDeviceItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Deposits ============
|
||||||
|
|
||||||
|
export interface DepositByMethodItem {
|
||||||
|
method: string;
|
||||||
|
count: number;
|
||||||
|
amount_kopeks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyDepositItem {
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
amount_kopeks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyDepositByMethodItem {
|
||||||
|
date: string;
|
||||||
|
method: string;
|
||||||
|
amount_kopeks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DepositsStats {
|
||||||
|
total_deposits: number;
|
||||||
|
total_amount_kopeks: number;
|
||||||
|
avg_deposit_kopeks: number;
|
||||||
|
by_method: DepositByMethodItem[];
|
||||||
|
daily: DailyDepositItem[];
|
||||||
|
daily_by_method: DailyDepositByMethodItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ API ============
|
||||||
|
|
||||||
|
export const salesStatsApi = {
|
||||||
|
getSummary: async (params: SalesStatsParams = {}): Promise<SalesSummary> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/stats/sales/summary', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTrials: async (params: SalesStatsParams = {}): Promise<TrialsStats> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/stats/sales/trials', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getSales: async (params: SalesStatsParams = {}): Promise<SalesStats> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/stats/sales/subscriptions', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getRenewals: async (params: SalesStatsParams = {}): Promise<RenewalsStats> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/stats/sales/renewals', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getAddons: async (params: SalesStatsParams = {}): Promise<AddonsStats> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/stats/sales/addons', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getDeposits: async (params: SalesStatsParams = {}): Promise<DepositsStats> => {
|
||||||
|
const response = await apiClient.get('/cabinet/admin/stats/sales/deposits', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -174,6 +174,7 @@ export const authApi = {
|
|||||||
provider: string,
|
provider: string,
|
||||||
code: string,
|
code: string,
|
||||||
state: string,
|
state: string,
|
||||||
|
deviceId?: string | null,
|
||||||
campaignSlug?: string | null,
|
campaignSlug?: string | null,
|
||||||
referralCode?: string | null,
|
referralCode?: string | null,
|
||||||
): Promise<AuthResponse> => {
|
): Promise<AuthResponse> => {
|
||||||
@@ -182,6 +183,7 @@ export const authApi = {
|
|||||||
{
|
{
|
||||||
code,
|
code,
|
||||||
state,
|
state,
|
||||||
|
device_id: deviceId || undefined,
|
||||||
campaign_slug: campaignSlug || undefined,
|
campaign_slug: campaignSlug || undefined,
|
||||||
referral_code: referralCode || undefined,
|
referral_code: referralCode || undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -149,6 +149,49 @@ export interface CampaignsOverview {
|
|||||||
total_tariff_issued: number;
|
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 {
|
export interface ServerSquadInfo {
|
||||||
id: number;
|
id: number;
|
||||||
squad_uuid: string;
|
squad_uuid: string;
|
||||||
@@ -258,4 +301,10 @@ export const campaignsApi = {
|
|||||||
const response = await apiClient.get('/cabinet/admin/campaigns/available-partners');
|
const response = await apiClient.get('/cabinet/admin/campaigns/available-partners');
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Get campaign chart data for analytics
|
||||||
|
getChartData: async (campaignId: number): Promise<AdminCampaignChartData> => {
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/chart-data`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface PartnerApplicationInfo {
|
|||||||
telegram_channel: string | null;
|
telegram_channel: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
expected_monthly_referrals: number | null;
|
expected_monthly_referrals: number | null;
|
||||||
|
desired_commission_percent: number | null;
|
||||||
admin_comment: string | null;
|
admin_comment: string | null;
|
||||||
approved_commission_percent: number | null;
|
approved_commission_percent: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -26,6 +27,10 @@ export interface PartnerCampaignInfo {
|
|||||||
subscription_traffic_gb: number | null;
|
subscription_traffic_gb: number | null;
|
||||||
deep_link: string | null;
|
deep_link: string | null;
|
||||||
web_link: string | null;
|
web_link: string | null;
|
||||||
|
// Per-campaign statistics
|
||||||
|
registrations_count: number;
|
||||||
|
referrals_count: number;
|
||||||
|
earnings_kopeks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PartnerStatusResponse {
|
export interface PartnerStatusResponse {
|
||||||
@@ -41,6 +46,58 @@ export interface PartnerApplicationRequest {
|
|||||||
telegram_channel?: string;
|
telegram_channel?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
expected_monthly_referrals?: number;
|
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 ====================
|
// ==================== Admin-facing types ====================
|
||||||
@@ -56,6 +113,7 @@ export interface AdminPartnerApplicationItem {
|
|||||||
telegram_channel: string | null;
|
telegram_channel: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
expected_monthly_referrals: number | null;
|
expected_monthly_referrals: number | null;
|
||||||
|
desired_commission_percent: number | null;
|
||||||
status: string;
|
status: string;
|
||||||
admin_comment: string | null;
|
admin_comment: string | null;
|
||||||
approved_commission_percent: number | null;
|
approved_commission_percent: number | null;
|
||||||
@@ -102,7 +160,15 @@ export interface AdminPartnerDetailResponse {
|
|||||||
earnings_week: number;
|
earnings_week: number;
|
||||||
earnings_month: number;
|
earnings_month: number;
|
||||||
conversion_to_paid: 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;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +214,13 @@ export const partnerApi = {
|
|||||||
return response.data;
|
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
|
// Admin endpoints
|
||||||
getStats: async (): Promise<PartnerStats> => {
|
getStats: async (): Promise<PartnerStats> => {
|
||||||
const response = await apiClient.get<PartnerStats>('/cabinet/admin/partners/stats');
|
const response = await apiClient.get<PartnerStats>('/cabinet/admin/partners/stats');
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ interface ReferralEarning {
|
|||||||
reason: string;
|
reason: string;
|
||||||
referral_username: string | null;
|
referral_username: string | null;
|
||||||
referral_first_name: string | null;
|
referral_first_name: string | null;
|
||||||
|
campaign_name: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface WheelConfig {
|
|||||||
can_pay_days: boolean;
|
can_pay_days: boolean;
|
||||||
user_balance_kopeks: number;
|
user_balance_kopeks: number;
|
||||||
required_balance_kopeks: number;
|
required_balance_kopeks: number;
|
||||||
|
has_subscription: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SpinAvailability {
|
export interface SpinAvailability {
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUseNow = () => {
|
const handleUseNow = () => {
|
||||||
navigate('/subscription', { state: { scrollToExtend: true } });
|
navigate('/subscription/purchase');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeactivateClick = () => {
|
const handleDeactivateClick = () => {
|
||||||
|
|||||||
@@ -149,24 +149,47 @@ export function ThemeTab() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update a single color in the draft and apply preview instantly
|
// Throttle applyThemeColors to once per animation frame
|
||||||
|
const rafRef = useRef(0);
|
||||||
|
const querySyncRef = useRef(0);
|
||||||
|
|
||||||
const updateDraftColor = useCallback(
|
const updateDraftColor = useCallback(
|
||||||
(key: keyof ThemeColors, value: string) => {
|
(key: keyof ThemeColors, value: string) => {
|
||||||
setDraftColors((prev) => {
|
setDraftColors((prev) => {
|
||||||
const next = { ...prev, [key]: value };
|
const next = { ...prev, [key]: value };
|
||||||
|
|
||||||
|
// Throttle CSS variable updates to 1x per frame
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = requestAnimationFrame(() => {
|
||||||
applyThemeColors(next);
|
applyThemeColors(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debounce query cache update (triggers re-renders of other components)
|
||||||
|
clearTimeout(querySyncRef.current);
|
||||||
|
querySyncRef.current = window.setTimeout(() => {
|
||||||
queryClient.setQueryData(['theme-colors'], next);
|
queryClient.setQueryData(['theme-colors'], next);
|
||||||
|
}, 150);
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[queryClient],
|
[queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(rafRef.current);
|
||||||
|
clearTimeout(querySyncRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Apply a full preset and auto-save to server
|
// Apply a full preset and auto-save to server
|
||||||
const applyPreset = useCallback(
|
const applyPreset = useCallback(
|
||||||
(colors: Partial<ThemeColors>) => {
|
(colors: Partial<ThemeColors>) => {
|
||||||
setDraftColors((prev) => {
|
setDraftColors((prev) => {
|
||||||
const next = { ...prev, ...colors };
|
const next = { ...prev, ...colors };
|
||||||
|
// Preset is a one-shot action — apply immediately (no throttle needed)
|
||||||
applyThemeColors(next);
|
applyThemeColors(next);
|
||||||
queryClient.setQueryData(['theme-colors'], next);
|
queryClient.setQueryData(['theme-colors'], next);
|
||||||
// Auto-save preset to server so it persists across navigation
|
// Auto-save preset to server so it persists across navigation
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Suspense, useMemo } from 'react';
|
import { Suspense, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { brandingApi } from '@/api/branding';
|
import { brandingApi } from '@/api/branding';
|
||||||
import type { AnimationConfig, BackgroundType } from '@/components/ui/backgrounds/types';
|
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';
|
import { backgroundComponents, prefetchBackground } from '@/components/ui/backgrounds/registry';
|
||||||
|
|
||||||
const ANIMATION_CACHE_KEY = 'cabinet_animation_config';
|
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 {
|
function getCachedConfig(): AnimationConfig | null {
|
||||||
try {
|
try {
|
||||||
const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefetch the background JS chunk immediately based on localStorage cache.
|
// 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();
|
const cachedConfig = getCachedConfig();
|
||||||
if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none') {
|
if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none') {
|
||||||
prefetchBackground(cachedConfig.type);
|
prefetchBackground(cachedConfig.type);
|
||||||
@@ -33,18 +72,27 @@ function setCachedConfig(config: AnimationConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function setCachedAnimationConfig(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> {
|
function reduceMobileSettings(settings: Record<string, unknown>): Record<string, unknown> {
|
||||||
const reduced = { ...settings };
|
const reduced = { ...settings };
|
||||||
|
// 75% reduction (divide by 4) instead of 50% — much less GPU work
|
||||||
if (typeof reduced.particleCount === 'number')
|
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')
|
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')
|
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 ('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;
|
return reduced;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +105,9 @@ export function BackgroundRenderer() {
|
|||||||
const { data: config } = useQuery({
|
const { data: config } = useQuery({
|
||||||
queryKey: ['animation-config'],
|
queryKey: ['animation-config'],
|
||||||
queryFn: async () => {
|
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);
|
setCachedConfig(result);
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
@@ -83,18 +133,24 @@ export function BackgroundRenderer() {
|
|||||||
? reduceMobileSettings(effectiveConfig.settings)
|
? reduceMobileSettings(effectiveConfig.settings)
|
||||||
: 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
|
<div
|
||||||
className="pointer-events-none fixed inset-0 z-0"
|
className="pointer-events-none fixed inset-0"
|
||||||
style={{
|
style={{
|
||||||
|
zIndex: -2,
|
||||||
opacity: effectiveConfig.opacity,
|
opacity: effectiveConfig.opacity,
|
||||||
filter: effectiveConfig.blur > 0 ? `blur(${effectiveConfig.blur}px)` : undefined,
|
filter: effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : undefined,
|
||||||
contain: 'strict',
|
contain: 'strict',
|
||||||
|
backfaceVisibility: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<Component settings={settings} />
|
<Component settings={settings} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ interface Props {
|
|||||||
onOpenDeepLink: (url: string) => void;
|
onOpenDeepLink: (url: string) => void;
|
||||||
isTelegramWebApp: boolean;
|
isTelegramWebApp: boolean;
|
||||||
onGoBack: () => void;
|
onGoBack: () => void;
|
||||||
|
onOpenQR?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function InstallationGuide({
|
export default function InstallationGuide({
|
||||||
@@ -50,6 +51,7 @@ export default function InstallationGuide({
|
|||||||
onOpenDeepLink,
|
onOpenDeepLink,
|
||||||
isTelegramWebApp,
|
isTelegramWebApp,
|
||||||
onGoBack,
|
onGoBack,
|
||||||
|
onOpenQR,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { isLight } = useTheme();
|
const { isLight } = useTheme();
|
||||||
@@ -206,6 +208,31 @@ export default function InstallationGuide({
|
|||||||
<h2 className="flex-1 text-lg font-bold text-dark-100">
|
<h2 className="flex-1 text-lg font-bold text-dark-100">
|
||||||
{getBaseTranslation('installationGuideHeader', 'subscription.connection.title')}
|
{getBaseTranslation('installationGuideHeader', 'subscription.connection.title')}
|
||||||
</h2>
|
</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 && (
|
{availablePlatforms.length > 1 && (
|
||||||
<div className="relative flex items-center">
|
<div className="relative flex items-center">
|
||||||
{currentPlatformSvg && (
|
{currentPlatformSvg && (
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export default function StatsGrid({
|
|||||||
refLoading,
|
refLoading,
|
||||||
}: StatsGridProps) {
|
}: StatsGridProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { formatAmount, currencySymbol, formatPositive } = useCurrency();
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const g = getGlassColors(isDark);
|
const g = getGlassColors(isDark);
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ export default function StatsGrid({
|
|||||||
label: t('dashboard.stats.referrals'),
|
label: t('dashboard.stats.referrals'),
|
||||||
value: `${referralCount}`,
|
value: `${referralCount}`,
|
||||||
valueColor: g.text,
|
valueColor: g.text,
|
||||||
subtitle: `${formatPositive(earningsRubles)} ${currencySymbol}`,
|
subtitle: `+${formatAmount(earningsRubles)} ${currencySymbol}`,
|
||||||
subtitleColor: zone.mainHex,
|
subtitleColor: zone.mainHex,
|
||||||
to: '/referral',
|
to: '/referral',
|
||||||
icon: (color: string) => (
|
icon: (color: string) => (
|
||||||
|
|||||||
@@ -73,9 +73,11 @@ export default function SubscriptionCardActive({
|
|||||||
background: g.cardBg,
|
background: g.cardBg,
|
||||||
border: subscription.is_trial
|
border: subscription.is_trial
|
||||||
? '1px solid rgba(62,219,176,0.15)'
|
? '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',
|
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 */}
|
{/* Trial shimmer border */}
|
||||||
@@ -240,7 +242,8 @@ export default function SubscriptionCardActive({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Device dots */}
|
{/* Device indicator */}
|
||||||
|
{subscription.device_limit <= 10 ? (
|
||||||
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
|
<div className="flex flex-shrink-0 gap-1.5" aria-hidden="true">
|
||||||
{Array.from({ length: subscription.device_limit }, (_, i) => (
|
{Array.from({ length: subscription.device_limit }, (_, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -253,6 +256,24 @@ export default function SubscriptionCardActive({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</HoverBorderGradient>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa
|
|||||||
className="relative overflow-hidden rounded-3xl"
|
className="relative overflow-hidden rounded-3xl"
|
||||||
style={{
|
style={{
|
||||||
background: g.cardBg,
|
background: g.cardBg,
|
||||||
border: '1px solid rgba(255,70,70,0.12)',
|
border: isDark ? '1px solid rgba(255,70,70,0.12)' : '1px solid rgba(255,59,92,0.2)',
|
||||||
boxShadow: g.shadow,
|
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',
|
padding: '28px 28px 24px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -40,18 +42,21 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa
|
|||||||
/>
|
/>
|
||||||
{/* Grid pattern */}
|
{/* Grid pattern */}
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-0 opacity-[0.02]"
|
className="pointer-events-none absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
opacity: isDark ? 0.02 : 0.04,
|
||||||
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
|
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',
|
backgroundSize: '40px 40px',
|
||||||
}}
|
}}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-5 flex items-start justify-between">
|
<div className="mb-5 flex items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Clock icon */}
|
{/* Clock icon */}
|
||||||
<div
|
<div
|
||||||
className="flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-[14px]"
|
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" />
|
<path d="M12 6v6l4 2" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-bold tracking-tight text-dark-50">
|
<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>
|
</h2>
|
||||||
<span className="text-xs text-dark-50/35">
|
|
||||||
{subscription.is_trial
|
|
||||||
? t('dashboard.expired.trialSubtitle')
|
|
||||||
: t('dashboard.expired.paidSubtitle')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Badge */}
|
{/* Expired date */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1 rounded-full px-3 py-1 text-[11px] font-semibold"
|
className="mb-5 flex items-center justify-center rounded-[14px]"
|
||||||
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"
|
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(255,59,92,0.04)',
|
background: 'rgba(255,59,92,0.04)',
|
||||||
border: '1px solid rgba(255,59,92,0.08)',
|
border: '1px solid rgba(255,59,92,0.08)',
|
||||||
padding: '16px 18px',
|
padding: '14px 18px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{[
|
<div className="mb-0.5 font-mono text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
|
||||||
{ label: t('dashboard.expired.traffic'), value: '0 GB' },
|
{t('dashboard.expired.expiredDate')}
|
||||||
{ 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>
|
</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>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex gap-2.5">
|
<div className="flex gap-2.5">
|
||||||
<Link
|
<Link
|
||||||
to="/subscription"
|
to="/subscription/purchase"
|
||||||
state={{ scrollToExtend: true }}
|
|
||||||
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"
|
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={{
|
style={{
|
||||||
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
|
background: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
|
||||||
@@ -154,7 +115,7 @@ export default function SubscriptionCardExpired({ subscription }: SubscriptionCa
|
|||||||
{t('dashboard.expired.renew')}
|
{t('dashboard.expired.renew')}
|
||||||
</Link>
|
</Link>
|
||||||
<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"
|
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={{
|
style={{
|
||||||
background: g.innerBg,
|
background: g.innerBg,
|
||||||
|
|||||||
@@ -33,8 +33,16 @@ export default function TrialOfferCard({
|
|||||||
className="relative overflow-hidden rounded-3xl text-center"
|
className="relative overflow-hidden rounded-3xl text-center"
|
||||||
style={{
|
style={{
|
||||||
background: g.cardBg,
|
background: g.cardBg,
|
||||||
border: `1px solid ${g.cardBorder}`,
|
border: isDark
|
||||||
boxShadow: g.shadow,
|
? `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',
|
padding: '32px 28px 28px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -55,10 +63,14 @@ export default function TrialOfferCard({
|
|||||||
/>
|
/>
|
||||||
{/* Grid pattern */}
|
{/* Grid pattern */}
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-0 opacity-[0.025]"
|
className="pointer-events-none absolute inset-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: `linear-gradient(rgba(255,255,255,0.1) 1px, transparent 1px),
|
opacity: isDark ? 0.025 : 0.04,
|
||||||
linear-gradient(90deg, rgba(255,255,255,0.1) 1px, transparent 1px)`,
|
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',
|
backgroundSize: '40px 40px',
|
||||||
}}
|
}}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -68,10 +80,14 @@ export default function TrialOfferCard({
|
|||||||
<div
|
<div
|
||||||
className="relative mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl"
|
className="relative mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl"
|
||||||
style={{
|
style={{
|
||||||
background: isFree
|
background: isDark
|
||||||
|
? isFree
|
||||||
? 'linear-gradient(135deg, #1a3a30, #142824)'
|
? 'linear-gradient(135deg, #1a3a30, #142824)'
|
||||||
: 'linear-gradient(135deg, #3a3020, #282418)',
|
: 'linear-gradient(135deg, #3a3020, #282418)'
|
||||||
border: isFree ? '1px solid rgba(62,219,176,0.2)' : '1px solid rgba(255,184,0,0.2)',
|
: 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',
|
transition: 'all 0.5s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -229,12 +245,21 @@ export default function TrialOfferCard({
|
|||||||
<button
|
<button
|
||||||
onClick={() => !activateTrialMutation.isPending && activateTrialMutation.mutate()}
|
onClick={() => !activateTrialMutation.isPending && activateTrialMutation.mutate()}
|
||||||
disabled={activateTrialMutation.isPending}
|
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"
|
className="w-full rounded-[14px] py-4 text-base font-bold tracking-tight transition-all duration-300 disabled:opacity-50"
|
||||||
style={{
|
style={
|
||||||
|
isDark
|
||||||
|
? {
|
||||||
background:
|
background:
|
||||||
'linear-gradient(135deg, rgba(62,219,176,0.12) 0%, rgba(62,219,176,0.04) 100%)',
|
'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)',
|
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')}
|
{activateTrialMutation.isPending ? t('common.loading') : t('subscription.trial.activate')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ const cardVariants = cva(
|
|||||||
'relative overflow-hidden',
|
'relative overflow-hidden',
|
||||||
'border border-dark-700/40 bg-dark-900/70',
|
'border border-dark-700/40 bg-dark-900/70',
|
||||||
'rounded-[var(--bento-radius)]',
|
'rounded-[var(--bento-radius)]',
|
||||||
'transition-all duration-200',
|
'transition-[border-color,background-color,box-shadow,transform,opacity] duration-200',
|
||||||
// GPU acceleration
|
|
||||||
'transform-gpu',
|
|
||||||
// Glass border inset
|
// Glass border inset
|
||||||
'shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05)]',
|
'shadow-[inset_0_1px_0_0_rgba(255,255,255,0.05)]',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -242,6 +242,22 @@ export const CheckIcon = ({ className }: IconProps) => (
|
|||||||
</svg>
|
</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) => (
|
export const XIcon = ({ className }: IconProps) => (
|
||||||
<svg
|
<svg
|
||||||
className={cn('h-4 w-4', className)}
|
className={cn('h-4 w-4', className)}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import CampaignBonusNotifier from '@/components/CampaignBonusNotifier';
|
|||||||
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
|
import SuccessNotificationModal from '@/components/SuccessNotificationModal';
|
||||||
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
import LanguageSwitcher from '@/components/LanguageSwitcher';
|
||||||
import TicketNotificationBell from '@/components/TicketNotificationBell';
|
import TicketNotificationBell from '@/components/TicketNotificationBell';
|
||||||
|
import { SubscriptionIcon } from '@/components/icons';
|
||||||
|
|
||||||
import { MobileBottomNav } from './MobileBottomNav';
|
import { MobileBottomNav } from './MobileBottomNav';
|
||||||
import { AppHeader } from './AppHeader';
|
import { AppHeader } from './AppHeader';
|
||||||
@@ -252,6 +253,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
// Desktop navigation items
|
// Desktop navigation items
|
||||||
const desktopNavItems = [
|
const desktopNavItems = [
|
||||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
||||||
|
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
|
||||||
{ path: '/balance', label: t('nav.balance'), icon: CreditCardIcon },
|
{ path: '/balance', label: t('nav.balance'), icon: CreditCardIcon },
|
||||||
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
|
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
|
||||||
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
|
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
|
||||||
@@ -278,7 +280,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{/* Animated background */}
|
{/* Animated background renders via portal on document.body at z-index: -1 */}
|
||||||
<BackgroundRenderer />
|
<BackgroundRenderer />
|
||||||
|
|
||||||
{/* Global components */}
|
{/* Global components */}
|
||||||
@@ -287,7 +289,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
<SuccessNotificationModal />
|
<SuccessNotificationModal />
|
||||||
|
|
||||||
{/* Desktop Header */}
|
{/* 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">
|
<div className="mx-auto grid h-14 max-w-6xl grid-cols-[auto_1fr_auto] items-center gap-4 px-6">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link to="/" className="flex items-center gap-2.5" onClick={handleNavClick}>
|
<Link to="/" className="flex items-center gap-2.5" onClick={handleNavClick}>
|
||||||
@@ -376,7 +378,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
toggleTheme();
|
toggleTheme();
|
||||||
}}
|
}}
|
||||||
className={cn(
|
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',
|
!canToggleTheme && 'pointer-events-none invisible',
|
||||||
)}
|
)}
|
||||||
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
|
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
|
||||||
@@ -390,7 +392,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
haptic.impact('light');
|
haptic.impact('light');
|
||||||
logout();
|
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')}
|
title={t('nav.logout')}
|
||||||
>
|
>
|
||||||
<LogoutIcon className="h-5 w-5" />
|
<LogoutIcon className="h-5 w-5" />
|
||||||
|
|||||||
200
src/components/partner/CampaignCard.tsx
Normal file
200
src/components/partner/CampaignCard.tsx
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { PartnerCampaignInfo } from '../../api/partners';
|
||||||
|
import { PARTNER_STATS } from '../../constants/partner';
|
||||||
|
import { useCurrency } from '../../hooks/useCurrency';
|
||||||
|
import { useHaptic } from '../../platform';
|
||||||
|
import { copyToClipboard } from '../../utils/clipboard';
|
||||||
|
import { CampaignDetailStats } from './CampaignDetailStats';
|
||||||
|
import { StatCard } from '../stats/StatCard';
|
||||||
|
|
||||||
|
const CopyIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const CheckIcon = () => (
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
||||||
|
<svg
|
||||||
|
className={`h-5 w-5 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface CampaignCardProps {
|
||||||
|
campaign: PartnerCampaignInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CampaignCard({ campaign }: CampaignCardProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { formatWithCurrency, formatPositive } = useCurrency();
|
||||||
|
const haptic = useHaptic();
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||||
|
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(
|
||||||
|
async (url: string, key: string) => {
|
||||||
|
try {
|
||||||
|
await copyToClipboard(url);
|
||||||
|
haptic.notification('success');
|
||||||
|
setCopiedLink(key);
|
||||||
|
clearTimeout(copyTimerRef.current);
|
||||||
|
copyTimerRef.current = setTimeout(
|
||||||
|
() => setCopiedLink(null),
|
||||||
|
PARTNER_STATS.COPY_FEEDBACK_MS,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
haptic.notification('error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[haptic],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleExpand = useCallback(() => {
|
||||||
|
haptic.impact('light');
|
||||||
|
setExpanded((prev) => !prev);
|
||||||
|
}, [haptic]);
|
||||||
|
|
||||||
|
const botKey = `${campaign.id}-bot`;
|
||||||
|
const webKey = `${campaign.id}-web`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bento-card space-y-4">
|
||||||
|
{/* Campaign header */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="min-w-0 truncate text-base font-semibold text-dark-100">{campaign.name}</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleToggleExpand}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
aria-controls={`campaign-detail-${campaign.id}`}
|
||||||
|
className="flex shrink-0 items-center gap-1 text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{expanded
|
||||||
|
? t('referral.partner.stats.hideDetails')
|
||||||
|
: t('referral.partner.stats.showDetails')}
|
||||||
|
</span>
|
||||||
|
<ChevronIcon expanded={expanded} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Basic stats -- always visible */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
label={t('referral.partner.stats.registrations')}
|
||||||
|
value={campaign.registrations_count}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('referral.partner.stats.referrals')}
|
||||||
|
value={campaign.referrals_count}
|
||||||
|
valueClassName="text-accent-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('referral.partner.stats.earnings')}
|
||||||
|
value={formatPositive(campaign.earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||||
|
valueClassName="text-success-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bonus info */}
|
||||||
|
{campaign.bonus_type !== 'none' && (
|
||||||
|
<div className="rounded-lg bg-success-500/10 p-3">
|
||||||
|
<div className="mb-1 text-xs font-medium text-success-500">
|
||||||
|
{t('referral.partner.campaignBonus.title')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-success-400">
|
||||||
|
{campaign.bonus_type === 'balance' &&
|
||||||
|
t('referral.partner.campaignBonus.balanceDesc', {
|
||||||
|
amount: formatWithCurrency(
|
||||||
|
campaign.balance_bonus_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
{campaign.bonus_type === 'subscription' &&
|
||||||
|
t('referral.partner.campaignBonus.subscriptionDesc', {
|
||||||
|
days: campaign.subscription_duration_days ?? 0,
|
||||||
|
...(campaign.subscription_traffic_gb
|
||||||
|
? { traffic: campaign.subscription_traffic_gb }
|
||||||
|
: {}),
|
||||||
|
})}
|
||||||
|
{campaign.bonus_type === 'tariff' && t('referral.partner.campaignBonus.tariffDesc')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bot link */}
|
||||||
|
{campaign.deep_link && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||||
|
{t('referral.partner.campaignLinks.bot')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={campaign.deep_link}
|
||||||
|
className="input flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(campaign.deep_link!, botKey)}
|
||||||
|
className={`btn-primary shrink-0 px-3 py-2.5 ${
|
||||||
|
copiedLink === botKey ? 'bg-success-500 hover:bg-success-500' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copiedLink === botKey ? <CheckIcon /> : <CopyIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Web link */}
|
||||||
|
{campaign.web_link && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||||
|
{t('referral.partner.campaignLinks.web')}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={campaign.web_link}
|
||||||
|
className="input flex-1 text-xs"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(campaign.web_link!, webKey)}
|
||||||
|
className={`btn-primary shrink-0 px-3 py-2.5 ${
|
||||||
|
copiedLink === webKey ? 'bg-success-500 hover:bg-success-500' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{copiedLink === webKey ? <CheckIcon /> : <CopyIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expanded detail stats */}
|
||||||
|
<div id={`campaign-detail-${campaign.id}`}>
|
||||||
|
{expanded && <CampaignDetailStats campaignId={campaign.id} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/components/partner/CampaignDetailStats.tsx
Normal file
100
src/components/partner/CampaignDetailStats.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { partnerApi } from '../../api/partners';
|
||||||
|
import { PARTNER_STATS } from '../../constants/partner';
|
||||||
|
import { useCurrency } from '../../hooks/useCurrency';
|
||||||
|
import { DailyChart } from '../stats/DailyChart';
|
||||||
|
import { PeriodComparison } from '../stats/PeriodComparison';
|
||||||
|
import { StatCard } from '../stats/StatCard';
|
||||||
|
import { TopReferrals } from './TopReferrals';
|
||||||
|
|
||||||
|
interface CampaignDetailStatsProps {
|
||||||
|
campaignId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CampaignDetailStats({ campaignId }: CampaignDetailStatsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { formatWithCurrency } = useCurrency();
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
|
queryKey: ['partner-campaign-stats', campaignId],
|
||||||
|
queryFn: () => partnerApi.getCampaignStats(campaignId),
|
||||||
|
staleTime: PARTNER_STATS.STATS_STALE_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
{/* Skeleton loader */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
{Array.from({ length: PARTNER_STATS.SKELETON_COUNT }).map((_, i) => (
|
||||||
|
<div key={i} className="h-16 animate-pulse rounded-xl bg-dark-800/30" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-52 animate-pulse rounded-xl bg-dark-800/30" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div className="pt-2 text-center">
|
||||||
|
<div className="text-sm text-error-400">{t('referral.partner.stats.noData')}</div>
|
||||||
|
<button onClick={() => refetch()} className="btn-secondary mt-2 px-4 py-1 text-xs">
|
||||||
|
{t('common.retry')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
{/* Period earnings */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
label={t('referral.partner.stats.today')}
|
||||||
|
value={formatWithCurrency(data.earnings_today / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||||
|
valueClassName="text-success-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('referral.partner.stats.week')}
|
||||||
|
value={formatWithCurrency(data.earnings_week / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||||
|
valueClassName="text-success-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('referral.partner.stats.month')}
|
||||||
|
value={formatWithCurrency(data.earnings_month / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||||
|
valueClassName="text-success-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conversion rate */}
|
||||||
|
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-dark-500">
|
||||||
|
{t('referral.partner.stats.conversionRate')}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-semibold text-accent-400">{data.conversion_rate}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-2 overflow-hidden rounded-full bg-dark-700">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-accent-500 transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(data.conversion_rate, PARTNER_STATS.MAX_CONVERSION_RATE)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Daily chart */}
|
||||||
|
<DailyChart data={data.daily_stats} chartId={campaignId} />
|
||||||
|
|
||||||
|
{/* Period comparison */}
|
||||||
|
<PeriodComparison data={data.period_comparison} />
|
||||||
|
|
||||||
|
{/* Top referrals */}
|
||||||
|
<TopReferrals referrals={data.top_referrals} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/components/partner/TopReferrals.tsx
Normal file
70
src/components/partner/TopReferrals.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { CampaignReferralItem } from '../../api/partners';
|
||||||
|
import { PARTNER_STATS } from '../../constants/partner';
|
||||||
|
import { useCurrency } from '../../hooks/useCurrency';
|
||||||
|
|
||||||
|
interface TopReferralsProps {
|
||||||
|
referrals: CampaignReferralItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
hasPaid: boolean;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ hasPaid, isActive }: StatusBadgeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
if (isActive) {
|
||||||
|
return <span className="badge-success">{t('referral.partner.stats.active')}</span>;
|
||||||
|
}
|
||||||
|
if (hasPaid) {
|
||||||
|
return <span className="badge-info">{t('referral.partner.stats.paid')}</span>;
|
||||||
|
}
|
||||||
|
return <span className="badge-neutral">{t('referral.partner.stats.pending')}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopReferrals({ referrals }: TopReferralsProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { formatWithCurrency } = useCurrency();
|
||||||
|
|
||||||
|
if (referrals.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bento-card py-6 text-center">
|
||||||
|
<div className="text-sm text-dark-400">{t('referral.partner.stats.noReferrals')}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bento-card">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">
|
||||||
|
{t('referral.partner.stats.topReferrals')}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{referrals.map((ref) => (
|
||||||
|
<div
|
||||||
|
key={ref.id}
|
||||||
|
className="flex items-center justify-between rounded-xl border border-dark-700/30 bg-dark-800/30 p-3"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="min-w-0 truncate text-sm font-medium text-dark-100">
|
||||||
|
{ref.full_name}
|
||||||
|
</span>
|
||||||
|
<StatusBadge hasPaid={ref.has_paid} isActive={ref.is_active} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-dark-500">
|
||||||
|
{new Date(ref.created_at).toLocaleDateString(i18n.language)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold text-success-400">
|
||||||
|
{formatWithCurrency(ref.total_earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/components/sales-stats/AddonsTab.tsx
Normal file
100
src/components/sales-stats/AddonsTab.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||||
|
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||||
|
import { SALES_STATS } from '../../constants/salesStats';
|
||||||
|
import { useCurrency } from '../../hooks/useCurrency';
|
||||||
|
import { StatCard } from '../stats';
|
||||||
|
|
||||||
|
import { DualAreaChart } from './DualAreaChart';
|
||||||
|
import { SimpleBarChart } from './SimpleBarChart';
|
||||||
|
|
||||||
|
interface AddonsTabProps {
|
||||||
|
params: SalesStatsParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddonsTab({ params }: AddonsTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { formatWithCurrency } = useCurrency();
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['sales-stats', 'addons', params],
|
||||||
|
queryFn: () => salesStatsApi.getAddons(params),
|
||||||
|
staleTime: SALES_STATS.STALE_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dailyChartData = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
const trafficByDate = new Map(data.daily.map((d) => [d.date, d.count]));
|
||||||
|
const deviceByDate = new Map(data.daily_devices.map((d) => [d.date, d.count]));
|
||||||
|
const allDates = Array.from(new Set([...trafficByDate.keys(), ...deviceByDate.keys()])).sort();
|
||||||
|
return allDates.map((date) => ({
|
||||||
|
date,
|
||||||
|
series1: trafficByDate.get(date) ?? 0,
|
||||||
|
series2: deviceByDate.get(date) ?? 0,
|
||||||
|
}));
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageBarData = data.by_package.map((item) => ({
|
||||||
|
name: `${item.traffic_gb} GB`,
|
||||||
|
value: item.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.addons.totalPurchases')}
|
||||||
|
value={data.total_purchases}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.addons.totalGb')}
|
||||||
|
value={`${data.total_gb_purchased} GB`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.addons.revenue')}
|
||||||
|
value={formatWithCurrency(data.addon_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||||
|
valueClassName="text-success-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.addons.devicePurchases')}
|
||||||
|
value={data.device_purchases}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.addons.deviceRevenue')}
|
||||||
|
value={formatWithCurrency(data.device_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||||
|
valueClassName="text-success-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<SimpleBarChart data={packageBarData} title={t('admin.salesStats.addons.byPackage')} />
|
||||||
|
<DualAreaChart
|
||||||
|
data={dailyChartData}
|
||||||
|
title={t('admin.salesStats.addons.dailyChart')}
|
||||||
|
chartId="addons-daily"
|
||||||
|
series1Label={t('admin.salesStats.addons.trafficPurchases')}
|
||||||
|
series2Label={t('admin.salesStats.addons.devicePurchasesDaily')}
|
||||||
|
series1Color={SALES_STATS.BAR_COLORS[5]}
|
||||||
|
series2Color={SALES_STATS.BAR_COLORS[3]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/components/sales-stats/DepositsTab.tsx
Normal file
128
src/components/sales-stats/DepositsTab.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||||
|
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||||
|
import { SALES_STATS } from '../../constants/salesStats';
|
||||||
|
import { useCurrency } from '../../hooks/useCurrency';
|
||||||
|
import { StatCard } from '../stats';
|
||||||
|
|
||||||
|
import { MultiSeriesAreaChart } from './MultiSeriesAreaChart';
|
||||||
|
import { SimpleAreaChart } from './SimpleAreaChart';
|
||||||
|
import { SimpleBarChart } from './SimpleBarChart';
|
||||||
|
|
||||||
|
interface DepositsTabProps {
|
||||||
|
params: SalesStatsParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHOD_LABELS: Record<string, string> = {
|
||||||
|
telegram_stars: 'Telegram Stars',
|
||||||
|
tribute: 'Tribute',
|
||||||
|
yookassa: 'YooKassa',
|
||||||
|
cryptobot: 'CryptoBot',
|
||||||
|
heleket: 'Heleket',
|
||||||
|
mulenpay: 'Mulenpay',
|
||||||
|
pal24: 'Pal24',
|
||||||
|
wata: 'Wata',
|
||||||
|
platega: 'Platega',
|
||||||
|
cloudpayments: 'CloudPayments',
|
||||||
|
freekassa: 'FreeKassa',
|
||||||
|
kassa_ai: 'Kassa AI',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DepositsTab({ params }: DepositsTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { formatWithCurrency } = useCurrency();
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['sales-stats', 'deposits', params],
|
||||||
|
queryFn: () => salesStatsApi.getDeposits(params),
|
||||||
|
staleTime: SALES_STATS.STALE_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatValue = useCallback((v: number) => formatWithCurrency(v), [formatWithCurrency]);
|
||||||
|
|
||||||
|
const methodBarData = useMemo(
|
||||||
|
() =>
|
||||||
|
data?.by_method.map((item) => ({
|
||||||
|
name: METHOD_LABELS[item.method] || item.method,
|
||||||
|
value: item.amount_kopeks / SALES_STATS.KOPEKS_DIVISOR,
|
||||||
|
})) ?? [],
|
||||||
|
[data?.by_method],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dailyData = useMemo(
|
||||||
|
() =>
|
||||||
|
data?.daily.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
value: item.amount_kopeks / SALES_STATS.KOPEKS_DIVISOR,
|
||||||
|
})) ?? [],
|
||||||
|
[data?.daily],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dailyByMethodData = useMemo(
|
||||||
|
() =>
|
||||||
|
data?.daily_by_method.map((i) => ({
|
||||||
|
date: i.date,
|
||||||
|
key: METHOD_LABELS[i.method] || i.method,
|
||||||
|
value: i.amount_kopeks / SALES_STATS.KOPEKS_DIVISOR,
|
||||||
|
})) ?? [],
|
||||||
|
[data?.daily_by_method],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.deposits.totalDeposits')}
|
||||||
|
value={data.total_deposits}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.deposits.totalAmount')}
|
||||||
|
value={formatWithCurrency(data.total_amount_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||||
|
valueClassName="text-success-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.deposits.avgDeposit')}
|
||||||
|
value={formatWithCurrency(data.avg_deposit_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SimpleBarChart
|
||||||
|
data={methodBarData}
|
||||||
|
title={t('admin.salesStats.deposits.byMethod')}
|
||||||
|
valueFormatter={formatValue}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SimpleAreaChart
|
||||||
|
data={dailyData}
|
||||||
|
title={t('admin.salesStats.deposits.dailyChart')}
|
||||||
|
chartId="deposits-daily"
|
||||||
|
valueLabel={t('admin.salesStats.deposits.revenue')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MultiSeriesAreaChart
|
||||||
|
data={dailyByMethodData}
|
||||||
|
title={t('admin.salesStats.deposits.dailyByMethod')}
|
||||||
|
chartId="deposits-daily-by-method"
|
||||||
|
valueLabel={t('admin.salesStats.deposits.revenue')}
|
||||||
|
valueFormatter={formatValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/components/sales-stats/DonutChart.tsx
Normal file
83
src/components/sales-stats/DonutChart.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
|
||||||
|
|
||||||
|
import { SALES_STATS } from '../../constants/salesStats';
|
||||||
|
import { useChartColors } from '../../hooks/useChartColors';
|
||||||
|
|
||||||
|
interface DonutChartProps {
|
||||||
|
data: { name: string; value: number; color?: string }[];
|
||||||
|
title: string;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DonutChart({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
height = SALES_STATS.CHART.PIE_HEIGHT,
|
||||||
|
}: DonutChartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useChartColors();
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bento-card">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||||
|
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
|
||||||
|
{t('common.noData')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bento-card">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={55}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
nameKey="name"
|
||||||
|
>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.name}
|
||||||
|
fill={entry.color || SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: colors.tooltipBg,
|
||||||
|
border: `1px solid ${colors.tooltipBorder}`,
|
||||||
|
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
|
||||||
|
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: colors.label }}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="mt-2 flex flex-wrap justify-center gap-3">
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<div key={entry.name} className="flex items-center gap-1.5 text-xs text-dark-400">
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-2.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
entry.color || SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{entry.name}: {entry.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/components/sales-stats/DualAreaChart.tsx
Normal file
151
src/components/sales-stats/DualAreaChart.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import { SALES_STATS } from '../../constants/salesStats';
|
||||||
|
import { useChartColors } from '../../hooks/useChartColors';
|
||||||
|
|
||||||
|
interface DualAreaChartProps {
|
||||||
|
data: { date: string; series1: number; series2: number }[];
|
||||||
|
title: string;
|
||||||
|
chartId: string;
|
||||||
|
series1Label: string;
|
||||||
|
series2Label: string;
|
||||||
|
series1Color?: string;
|
||||||
|
series2Color?: string;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DualAreaChart({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
chartId,
|
||||||
|
series1Label,
|
||||||
|
series2Label,
|
||||||
|
series1Color,
|
||||||
|
series2Color,
|
||||||
|
height = SALES_STATS.CHART.HEIGHT,
|
||||||
|
}: DualAreaChartProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const colors = useChartColors();
|
||||||
|
const color1 = series1Color || colors.referrals;
|
||||||
|
const color2 = series2Color || colors.earnings;
|
||||||
|
|
||||||
|
const chartData = useMemo(
|
||||||
|
() =>
|
||||||
|
data.map((item) => ({
|
||||||
|
...item,
|
||||||
|
label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
[data, i18n.language],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bento-card">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||||
|
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
|
||||||
|
{t('common.noData')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bento-card">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<AreaChart data={chartData} margin={SALES_STATS.CHART.MARGIN}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`gradient-s1-${chartId}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset={SALES_STATS.GRADIENT.START_OFFSET}
|
||||||
|
stopColor={color1}
|
||||||
|
stopOpacity={SALES_STATS.GRADIENT.START_OPACITY}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset={SALES_STATS.GRADIENT.END_OFFSET}
|
||||||
|
stopColor={color1}
|
||||||
|
stopOpacity={SALES_STATS.GRADIENT.END_OPACITY}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={`gradient-s2-${chartId}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset={SALES_STATS.GRADIENT.START_OFFSET}
|
||||||
|
stopColor={color2}
|
||||||
|
stopOpacity={SALES_STATS.GRADIENT.START_OPACITY}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset={SALES_STATS.GRADIENT.END_OFFSET}
|
||||||
|
stopColor={color2}
|
||||||
|
stopOpacity={SALES_STATS.GRADIENT.END_OPACITY}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray={SALES_STATS.GRID_DASH} stroke={colors.grid} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||||
|
tickLine={false}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={SALES_STATS.AXIS.WIDTH}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: colors.tooltipBg,
|
||||||
|
border: `1px solid ${colors.tooltipBorder}`,
|
||||||
|
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
|
||||||
|
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: colors.label }}
|
||||||
|
formatter={(value: number | undefined, name: string | undefined) => {
|
||||||
|
const displayValue = value ?? 0;
|
||||||
|
if (name === 'series1') return [displayValue, series1Label];
|
||||||
|
return [displayValue, series2Label];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
formatter={(value: string) => {
|
||||||
|
if (value === 'series1') return series1Label;
|
||||||
|
return series2Label;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="series1"
|
||||||
|
name="series1"
|
||||||
|
stroke={color1}
|
||||||
|
fill={`url(#gradient-s1-${chartId})`}
|
||||||
|
strokeWidth={SALES_STATS.STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="series2"
|
||||||
|
name="series2"
|
||||||
|
stroke={color2}
|
||||||
|
fill={`url(#gradient-s2-${chartId})`}
|
||||||
|
strokeWidth={SALES_STATS.STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
src/components/sales-stats/MultiSeriesAreaChart.tsx
Normal file
157
src/components/sales-stats/MultiSeriesAreaChart.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import { SALES_STATS } from '../../constants/salesStats';
|
||||||
|
import { useChartColors } from '../../hooks/useChartColors';
|
||||||
|
|
||||||
|
interface MultiSeriesAreaChartProps {
|
||||||
|
data: { date: string; key: string; value: number }[];
|
||||||
|
title: string;
|
||||||
|
chartId: string;
|
||||||
|
valueLabel?: string;
|
||||||
|
valueFormatter?: (value: number) => string;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MultiSeriesAreaChart({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
chartId,
|
||||||
|
valueLabel,
|
||||||
|
valueFormatter,
|
||||||
|
height = SALES_STATS.CHART.HEIGHT,
|
||||||
|
}: MultiSeriesAreaChartProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const colors = useChartColors();
|
||||||
|
|
||||||
|
// Pivot flat data into { label, [key1]: value, [key2]: value, ... }
|
||||||
|
const { chartData, seriesKeys } = useMemo(() => {
|
||||||
|
const dateMap = new Map<string, Record<string, number>>();
|
||||||
|
const keySet = new Set<string>();
|
||||||
|
|
||||||
|
for (const item of data) {
|
||||||
|
keySet.add(item.key);
|
||||||
|
const existing = dateMap.get(item.date) || {};
|
||||||
|
existing[item.key] = (existing[item.key] || 0) + item.value;
|
||||||
|
dateMap.set(item.date, existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Array.from(keySet).sort();
|
||||||
|
const sortedDates = Array.from(dateMap.keys()).sort();
|
||||||
|
const pivoted = sortedDates.map((date) => {
|
||||||
|
const row: Record<string, string | number> = {
|
||||||
|
date,
|
||||||
|
label: new Date(date + 'T00:00:00').toLocaleDateString(i18n.language, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const values = dateMap.get(date) || {};
|
||||||
|
for (const key of keys) {
|
||||||
|
row[key] = values[key] || 0;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { chartData: pivoted, seriesKeys: keys };
|
||||||
|
}, [data, i18n.language]);
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bento-card">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||||
|
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
|
||||||
|
{t('common.noData')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bento-card">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<AreaChart data={chartData} margin={SALES_STATS.CHART.MARGIN}>
|
||||||
|
<defs>
|
||||||
|
{seriesKeys.map((key, i) => {
|
||||||
|
const color = SALES_STATS.BAR_COLORS[i % SALES_STATS.BAR_COLORS.length];
|
||||||
|
return (
|
||||||
|
<linearGradient
|
||||||
|
key={key}
|
||||||
|
id={`gradient-${chartId}-${i}`}
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop
|
||||||
|
offset={SALES_STATS.GRADIENT.START_OFFSET}
|
||||||
|
stopColor={color}
|
||||||
|
stopOpacity={SALES_STATS.GRADIENT.START_OPACITY}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset={SALES_STATS.GRADIENT.END_OFFSET}
|
||||||
|
stopColor={color}
|
||||||
|
stopOpacity={SALES_STATS.GRADIENT.END_OPACITY}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray={SALES_STATS.GRID_DASH} stroke={colors.grid} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||||
|
tickLine={false}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={SALES_STATS.AXIS.WIDTH}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: colors.tooltipBg,
|
||||||
|
border: `1px solid ${colors.tooltipBorder}`,
|
||||||
|
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
|
||||||
|
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: colors.label }}
|
||||||
|
formatter={(value: number | undefined, name: string | undefined) => [
|
||||||
|
valueFormatter ? valueFormatter(value ?? 0) : (value ?? 0),
|
||||||
|
name || valueLabel || '',
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
{seriesKeys.map((key, i) => {
|
||||||
|
const color = SALES_STATS.BAR_COLORS[i % SALES_STATS.BAR_COLORS.length];
|
||||||
|
return (
|
||||||
|
<Area
|
||||||
|
key={key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={key}
|
||||||
|
name={key}
|
||||||
|
stroke={color}
|
||||||
|
fill={`url(#gradient-${chartId}-${i})`}
|
||||||
|
strokeWidth={SALES_STATS.STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/sales-stats/PeriodSelector.tsx
Normal file
89
src/components/sales-stats/PeriodSelector.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { SALES_STATS } from '../../constants/salesStats';
|
||||||
|
|
||||||
|
interface PeriodSelectorProps {
|
||||||
|
value: { days?: number; startDate?: string; endDate?: string };
|
||||||
|
onChange: (period: { days?: number; startDate?: string; endDate?: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [showCustom, setShowCustom] = useState(false);
|
||||||
|
|
||||||
|
const presetLabels: Record<number, string> = {
|
||||||
|
7: t('admin.salesStats.period.week'),
|
||||||
|
30: t('admin.salesStats.period.month'),
|
||||||
|
90: t('admin.salesStats.period.quarter'),
|
||||||
|
0: t('admin.salesStats.period.all'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreset = (days: number) => {
|
||||||
|
setShowCustom(false);
|
||||||
|
onChange({ days });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomToggle = () => {
|
||||||
|
setShowCustom((prev) => !prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateChange = (field: 'startDate' | 'endDate', dateStr: string) => {
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
days: undefined,
|
||||||
|
[field]: dateStr,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPresetActive = (days: number) => !showCustom && value.days === days;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{SALES_STATS.PERIOD_PRESETS.map((days) => (
|
||||||
|
<button
|
||||||
|
key={days}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePreset(days)}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
isPresetActive(days)
|
||||||
|
? 'bg-accent-500/20 text-accent-400'
|
||||||
|
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{presetLabels[days]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCustomToggle}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
showCustom
|
||||||
|
? 'bg-accent-500/20 text-accent-400'
|
||||||
|
: 'bg-dark-800/50 text-dark-400 hover:bg-dark-700/50 hover:text-dark-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t('admin.salesStats.period.custom')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showCustom && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={value.startDate || ''}
|
||||||
|
onChange={(e) => handleDateChange('startDate', e.target.value)}
|
||||||
|
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
|
||||||
|
/>
|
||||||
|
<span className="text-dark-500">{'\u2014'}</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={value.endDate || ''}
|
||||||
|
onChange={(e) => handleDateChange('endDate', e.target.value)}
|
||||||
|
className="rounded-lg border border-dark-600 bg-dark-800 px-2 py-1 text-sm text-dark-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/components/sales-stats/RenewalsTab.tsx
Normal file
102
src/components/sales-stats/RenewalsTab.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||||
|
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||||
|
import { SALES_STATS } from '../../constants/salesStats';
|
||||||
|
import { useCurrency } from '../../hooks/useCurrency';
|
||||||
|
import { StatCard } from '../stats';
|
||||||
|
import { TREND_STYLES } from '../stats/constants';
|
||||||
|
|
||||||
|
import { SimpleAreaChart } from './SimpleAreaChart';
|
||||||
|
|
||||||
|
interface RenewalsTabProps {
|
||||||
|
params: SalesStatsParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RenewalsTab({ params }: RenewalsTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { formatWithCurrency } = useCurrency();
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['sales-stats', 'renewals', params],
|
||||||
|
queryFn: () => salesStatsApi.getRenewals(params),
|
||||||
|
staleTime: SALES_STATS.STALE_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyData = data.daily.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
value: item.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const trendStyle = TREND_STYLES[data.change.trend] ?? TREND_STYLES.stable;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<StatCard label={t('admin.salesStats.renewals.total')} value={data.total_renewals} />
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.renewals.rate')}
|
||||||
|
value={`${data.renewal_rate}%`}
|
||||||
|
valueClassName="text-success-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.renewals.revenue')}
|
||||||
|
value={formatWithCurrency(data.total_revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||||
|
valueClassName="text-success-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bento-card">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">
|
||||||
|
{t('admin.salesStats.renewals.comparison')}
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||||
|
<div className="text-xs text-dark-500">
|
||||||
|
{t('admin.salesStats.renewals.currentPeriod')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-baseline gap-2">
|
||||||
|
<span className="text-base font-semibold text-dark-100 sm:text-lg">
|
||||||
|
{data.current_period.count}
|
||||||
|
</span>
|
||||||
|
<span className={`text-sm font-medium ${trendStyle.className}`}>
|
||||||
|
{trendStyle.arrow} {Math.abs(data.change.percent)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||||
|
<div className="text-xs text-dark-500">
|
||||||
|
{t('admin.salesStats.renewals.previousPeriod')}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-base font-semibold text-dark-400 sm:text-lg">
|
||||||
|
{data.previous_period.count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SimpleAreaChart
|
||||||
|
data={dailyData}
|
||||||
|
title={t('admin.salesStats.renewals.dailyChart')}
|
||||||
|
chartId="renewals-daily"
|
||||||
|
valueLabel={t('admin.salesStats.renewals.renewals')}
|
||||||
|
color={SALES_STATS.BAR_COLORS[2]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/sales-stats/SalesTab.tsx
Normal file
98
src/components/sales-stats/SalesTab.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||||
|
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||||
|
import { SALES_STATS } from '../../constants/salesStats';
|
||||||
|
import { useCurrency } from '../../hooks/useCurrency';
|
||||||
|
import { StatCard } from '../stats';
|
||||||
|
|
||||||
|
import { DonutChart } from './DonutChart';
|
||||||
|
import { MultiSeriesAreaChart } from './MultiSeriesAreaChart';
|
||||||
|
import { SimpleAreaChart } from './SimpleAreaChart';
|
||||||
|
import { SimpleBarChart } from './SimpleBarChart';
|
||||||
|
|
||||||
|
interface SalesTabProps {
|
||||||
|
params: SalesStatsParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SalesTab({ params }: SalesTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { formatWithCurrency } = useCurrency();
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['sales-stats', 'sales', params],
|
||||||
|
queryFn: () => salesStatsApi.getSales(params),
|
||||||
|
staleTime: SALES_STATS.STALE_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
|
const dailyByTariffData = useMemo(
|
||||||
|
() =>
|
||||||
|
data?.daily_by_tariff.map((i) => ({ date: i.date, key: i.tariff_name, value: i.count })) ??
|
||||||
|
[],
|
||||||
|
[data?.daily_by_tariff],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tariffBarData = data.by_tariff.map((item) => ({
|
||||||
|
name: item.tariff_name,
|
||||||
|
value: item.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const periodPieData = data.by_period.map((item) => ({
|
||||||
|
name: `${item.period_days} ${t('admin.trafficUsage.days')}`,
|
||||||
|
value: item.count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dailyData = data.daily.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
value: item.revenue_kopeks / SALES_STATS.KOPEKS_DIVISOR,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<StatCard label={t('admin.salesStats.sales.totalSales')} value={data.total_sales} />
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.sales.avgOrder')}
|
||||||
|
value={formatWithCurrency(data.avg_order_kopeks / SALES_STATS.KOPEKS_DIVISOR)}
|
||||||
|
valueClassName="text-success-400"
|
||||||
|
/>
|
||||||
|
<StatCard label={t('admin.salesStats.sales.topTariff')} value={data.top_tariff_name} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<SimpleBarChart data={tariffBarData} title={t('admin.salesStats.sales.byTariff')} />
|
||||||
|
<DonutChart data={periodPieData} title={t('admin.salesStats.sales.byPeriod')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SimpleAreaChart
|
||||||
|
data={dailyData}
|
||||||
|
title={t('admin.salesStats.sales.dailyChart')}
|
||||||
|
chartId="sales-daily"
|
||||||
|
valueLabel={t('admin.salesStats.summary.revenue')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MultiSeriesAreaChart
|
||||||
|
data={dailyByTariffData}
|
||||||
|
title={t('admin.salesStats.sales.dailyByTariff')}
|
||||||
|
chartId="sales-daily-by-tariff"
|
||||||
|
valueLabel={t('admin.salesStats.sales.subscriptions')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/components/sales-stats/SimpleAreaChart.tsx
Normal file
114
src/components/sales-stats/SimpleAreaChart.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import { SALES_STATS } from '../../constants/salesStats';
|
||||||
|
import { useChartColors } from '../../hooks/useChartColors';
|
||||||
|
|
||||||
|
interface SimpleAreaChartProps {
|
||||||
|
data: { date: string; value: number }[];
|
||||||
|
title: string;
|
||||||
|
color?: string;
|
||||||
|
chartId: string;
|
||||||
|
valueLabel?: string;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleAreaChart({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
color,
|
||||||
|
chartId,
|
||||||
|
valueLabel,
|
||||||
|
height = SALES_STATS.CHART.HEIGHT,
|
||||||
|
}: SimpleAreaChartProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const colors = useChartColors();
|
||||||
|
const strokeColor = color || colors.earnings;
|
||||||
|
|
||||||
|
const chartData = useMemo(
|
||||||
|
() =>
|
||||||
|
data.map((item) => ({
|
||||||
|
...item,
|
||||||
|
label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
[data, i18n.language],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bento-card">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||||
|
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
|
||||||
|
{t('common.noData')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bento-card">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<AreaChart data={chartData} margin={SALES_STATS.CHART.MARGIN}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`gradient-${chartId}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset={SALES_STATS.GRADIENT.START_OFFSET}
|
||||||
|
stopColor={strokeColor}
|
||||||
|
stopOpacity={SALES_STATS.GRADIENT.START_OPACITY}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset={SALES_STATS.GRADIENT.END_OFFSET}
|
||||||
|
stopColor={strokeColor}
|
||||||
|
stopOpacity={SALES_STATS.GRADIENT.END_OPACITY}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray={SALES_STATS.GRID_DASH} stroke={colors.grid} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||||
|
tickLine={false}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={SALES_STATS.AXIS.WIDTH}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: colors.tooltipBg,
|
||||||
|
border: `1px solid ${colors.tooltipBorder}`,
|
||||||
|
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
|
||||||
|
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: colors.label }}
|
||||||
|
formatter={(value: number | undefined) => [value ?? 0, valueLabel || '']}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={strokeColor}
|
||||||
|
fill={`url(#gradient-${chartId})`}
|
||||||
|
strokeWidth={SALES_STATS.STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
src/components/sales-stats/SimpleBarChart.tsx
Normal file
98
src/components/sales-stats/SimpleBarChart.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Bar,
|
||||||
|
BarChart,
|
||||||
|
CartesianGrid,
|
||||||
|
Cell,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import { SALES_STATS } from '../../constants/salesStats';
|
||||||
|
import { useChartColors } from '../../hooks/useChartColors';
|
||||||
|
|
||||||
|
interface SimpleBarChartProps {
|
||||||
|
data: { name: string; value: number; color?: string }[];
|
||||||
|
title: string;
|
||||||
|
height?: number;
|
||||||
|
valueFormatter?: (value: number) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BAR_CHART_MARGIN = {
|
||||||
|
...SALES_STATS.CHART.MARGIN,
|
||||||
|
bottom: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SimpleBarChart({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
height = SALES_STATS.CHART.HEIGHT,
|
||||||
|
valueFormatter,
|
||||||
|
}: SimpleBarChartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useChartColors();
|
||||||
|
|
||||||
|
const adjustedHeight = useMemo(() => height + 35, [height]);
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bento-card">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||||
|
<div className="flex items-center justify-center text-sm text-dark-400" style={{ height }}>
|
||||||
|
{t('common.noData')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bento-card">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">{title}</h4>
|
||||||
|
<ResponsiveContainer width="100%" height={adjustedHeight}>
|
||||||
|
<BarChart data={data} margin={BAR_CHART_MARGIN}>
|
||||||
|
<CartesianGrid strokeDasharray={SALES_STATS.GRID_DASH} stroke={colors.grid} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||||
|
tickLine={false}
|
||||||
|
angle={-30}
|
||||||
|
textAnchor="end"
|
||||||
|
height={60}
|
||||||
|
interval={0}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: colors.tick, fontSize: SALES_STATS.AXIS.TICK_FONT_SIZE }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={SALES_STATS.AXIS.WIDTH}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ fill: colors.grid, fillOpacity: 0.3 }}
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: colors.tooltipBg,
|
||||||
|
border: `1px solid ${colors.tooltipBorder}`,
|
||||||
|
borderRadius: SALES_STATS.TOOLTIP.BORDER_RADIUS,
|
||||||
|
fontSize: SALES_STATS.TOOLTIP.FONT_SIZE,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: colors.label }}
|
||||||
|
formatter={(value: number | undefined) => [
|
||||||
|
valueFormatter ? valueFormatter(value ?? 0) : (value ?? 0),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="value" radius={[4, 4, 0, 0]}>
|
||||||
|
{data.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.name}
|
||||||
|
fill={entry.color || SALES_STATS.BAR_COLORS[index % SALES_STATS.BAR_COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/sales-stats/TrialsTab.tsx
Normal file
91
src/components/sales-stats/TrialsTab.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { SalesStatsParams } from '../../api/adminSalesStats';
|
||||||
|
import { salesStatsApi } from '../../api/adminSalesStats';
|
||||||
|
import { SALES_STATS } from '../../constants/salesStats';
|
||||||
|
import { StatCard } from '../stats';
|
||||||
|
|
||||||
|
import { DonutChart } from './DonutChart';
|
||||||
|
import { DualAreaChart } from './DualAreaChart';
|
||||||
|
|
||||||
|
interface TrialsTabProps {
|
||||||
|
params: SalesStatsParams;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDER_LABELS: Record<string, string> = {
|
||||||
|
telegram: 'Telegram',
|
||||||
|
email: 'Email',
|
||||||
|
vk: 'VK',
|
||||||
|
yandex: 'Yandex',
|
||||||
|
google: 'Google',
|
||||||
|
discord: 'Discord',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TrialsTab({ params }: TrialsTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['sales-stats', 'trials', params],
|
||||||
|
queryFn: () => salesStatsApi.getTrials(params),
|
||||||
|
staleTime: SALES_STATS.STALE_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="animate-pulse space-y-4">
|
||||||
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
|
<div key={i} className="h-24 rounded-xl bg-dark-800/30" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return <div className="py-8 text-center text-red-400">{t('admin.salesStats.loadError')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pieData = data.by_provider.map((item) => ({
|
||||||
|
name: PROVIDER_LABELS[item.provider] || item.provider,
|
||||||
|
value: item.count,
|
||||||
|
color: SALES_STATS.PROVIDER_COLORS[item.provider as keyof typeof SALES_STATS.PROVIDER_COLORS],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const dualData = data.daily.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
series1: item.registrations,
|
||||||
|
series2: item.trials,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.trials.totalRegistrations')}
|
||||||
|
value={data.total_registrations}
|
||||||
|
/>
|
||||||
|
<StatCard label={t('admin.salesStats.trials.total')} value={data.total_trials} />
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.trials.conversion')}
|
||||||
|
value={`${data.conversion_rate}%`}
|
||||||
|
valueClassName="text-success-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.trials.avgDuration')}
|
||||||
|
value={`${data.avg_trial_duration_days} ${t('admin.trafficUsage.days')}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||||
|
<DonutChart data={pieData} title={t('admin.salesStats.trials.byProvider')} />
|
||||||
|
<DualAreaChart
|
||||||
|
data={dualData}
|
||||||
|
title={t('admin.salesStats.trials.dailyChart')}
|
||||||
|
chartId="trials-daily"
|
||||||
|
series1Label={t('admin.salesStats.trials.totalRegistrations')}
|
||||||
|
series2Label={t('admin.salesStats.trials.trialsIssued')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/components/sales-stats/index.ts
Normal file
11
src/components/sales-stats/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { AddonsTab } from './AddonsTab';
|
||||||
|
export { DepositsTab } from './DepositsTab';
|
||||||
|
export { DonutChart } from './DonutChart';
|
||||||
|
export { DualAreaChart } from './DualAreaChart';
|
||||||
|
export { MultiSeriesAreaChart } from './MultiSeriesAreaChart';
|
||||||
|
export { PeriodSelector } from './PeriodSelector';
|
||||||
|
export { RenewalsTab } from './RenewalsTab';
|
||||||
|
export { SalesTab } from './SalesTab';
|
||||||
|
export { SimpleAreaChart } from './SimpleAreaChart';
|
||||||
|
export { SimpleBarChart } from './SimpleBarChart';
|
||||||
|
export { TrialsTab } from './TrialsTab';
|
||||||
144
src/components/stats/DailyChart.tsx
Normal file
144
src/components/stats/DailyChart.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
AreaChart,
|
||||||
|
CartesianGrid,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import type { DailyStatItem } from './types';
|
||||||
|
import { PARTNER_STATS } from '../../constants/partner';
|
||||||
|
import { useCurrency } from '../../hooks/useCurrency';
|
||||||
|
import { useChartColors } from '../../hooks/useChartColors';
|
||||||
|
|
||||||
|
interface DailyChartProps {
|
||||||
|
data: DailyStatItem[];
|
||||||
|
chartId: string | number;
|
||||||
|
title?: string;
|
||||||
|
earningsLabel?: string;
|
||||||
|
countLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartDataItem extends DailyStatItem {
|
||||||
|
earnings_display: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DailyChart({ data, chartId, title, earningsLabel, countLabel }: DailyChartProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { formatWithCurrency } = useCurrency();
|
||||||
|
const colors = useChartColors();
|
||||||
|
|
||||||
|
const resolvedTitle = title ?? t('referral.partner.stats.dailyChart');
|
||||||
|
const resolvedEarningsLabel = earningsLabel ?? t('referral.partner.stats.earnings');
|
||||||
|
const resolvedCountLabel = countLabel ?? t('referral.partner.stats.referrals');
|
||||||
|
|
||||||
|
const chartData: ChartDataItem[] = useMemo(
|
||||||
|
() =>
|
||||||
|
data.map((item) => ({
|
||||||
|
...item,
|
||||||
|
earnings_display: item.earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||||
|
label: new Date(item.date + 'T00:00:00').toLocaleDateString(i18n.language, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
[data, i18n.language],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bento-card">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">{resolvedTitle}</h4>
|
||||||
|
<ResponsiveContainer width="100%" height={PARTNER_STATS.CHART.HEIGHT}>
|
||||||
|
<AreaChart data={chartData} margin={PARTNER_STATS.CHART.MARGIN}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`earningsGradient-${chartId}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset={PARTNER_STATS.GRADIENT.START_OFFSET}
|
||||||
|
stopColor={colors.earnings}
|
||||||
|
stopOpacity={PARTNER_STATS.GRADIENT.START_OPACITY}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset={PARTNER_STATS.GRADIENT.END_OFFSET}
|
||||||
|
stopColor={colors.earnings}
|
||||||
|
stopOpacity={PARTNER_STATS.GRADIENT.END_OPACITY}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id={`referralsGradient-${chartId}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset={PARTNER_STATS.GRADIENT.START_OFFSET}
|
||||||
|
stopColor={colors.referrals}
|
||||||
|
stopOpacity={PARTNER_STATS.GRADIENT.START_OPACITY}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset={PARTNER_STATS.GRADIENT.END_OFFSET}
|
||||||
|
stopColor={colors.referrals}
|
||||||
|
stopOpacity={PARTNER_STATS.GRADIENT.END_OPACITY}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray={PARTNER_STATS.GRID_DASH} stroke={colors.grid} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
tick={{ fill: colors.tick, fontSize: PARTNER_STATS.AXIS.TICK_FONT_SIZE }}
|
||||||
|
tickLine={false}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="earnings"
|
||||||
|
orientation="right"
|
||||||
|
tick={{ fill: colors.earnings, fontSize: PARTNER_STATS.AXIS.TICK_FONT_SIZE }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={PARTNER_STATS.AXIS.EARNINGS_WIDTH}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
yAxisId="referrals"
|
||||||
|
orientation="left"
|
||||||
|
tick={{ fill: colors.referrals, fontSize: PARTNER_STATS.AXIS.TICK_FONT_SIZE }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={PARTNER_STATS.AXIS.REFERRALS_WIDTH}
|
||||||
|
allowDecimals={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: colors.tooltipBg,
|
||||||
|
border: `1px solid ${colors.tooltipBorder}`,
|
||||||
|
borderRadius: PARTNER_STATS.TOOLTIP.BORDER_RADIUS,
|
||||||
|
fontSize: PARTNER_STATS.TOOLTIP.FONT_SIZE,
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: colors.label }}
|
||||||
|
formatter={(value: number | undefined, name: string | undefined) => {
|
||||||
|
const displayValue = value ?? 0;
|
||||||
|
if (name === 'earnings_display') {
|
||||||
|
return [formatWithCurrency(displayValue), resolvedEarningsLabel];
|
||||||
|
}
|
||||||
|
return [displayValue, resolvedCountLabel];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
yAxisId="earnings"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="earnings_display"
|
||||||
|
stroke={colors.earnings}
|
||||||
|
fill={`url(#earningsGradient-${chartId})`}
|
||||||
|
strokeWidth={PARTNER_STATS.STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
yAxisId="referrals"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="referrals_count"
|
||||||
|
stroke={colors.referrals}
|
||||||
|
fill={`url(#referralsGradient-${chartId})`}
|
||||||
|
strokeWidth={PARTNER_STATS.STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
78
src/components/stats/PeriodComparison.tsx
Normal file
78
src/components/stats/PeriodComparison.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { PeriodComparison as PeriodComparisonType } from './types';
|
||||||
|
import { TREND_STYLES } from './constants';
|
||||||
|
import { PARTNER_STATS } from '../../constants/partner';
|
||||||
|
import { useCurrency } from '../../hooks/useCurrency';
|
||||||
|
|
||||||
|
interface PeriodComparisonProps {
|
||||||
|
data: PeriodComparisonType;
|
||||||
|
title?: string;
|
||||||
|
countLabel?: string;
|
||||||
|
earningsLabel?: string;
|
||||||
|
comparisonLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrendBadgeProps {
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendBadge({ trend, percent }: TrendBadgeProps) {
|
||||||
|
const style = TREND_STYLES[trend];
|
||||||
|
return (
|
||||||
|
<span className={`text-sm font-medium ${style.className}`}>
|
||||||
|
{style.arrow} {Math.abs(percent)}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PeriodComparison({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
countLabel,
|
||||||
|
earningsLabel,
|
||||||
|
comparisonLabel,
|
||||||
|
}: PeriodComparisonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { formatWithCurrency } = useCurrency();
|
||||||
|
|
||||||
|
const resolvedTitle = title ?? t('referral.partner.stats.periodComparison');
|
||||||
|
const resolvedCountLabel = countLabel ?? t('referral.partner.stats.referralsCount');
|
||||||
|
const resolvedEarningsLabel = earningsLabel ?? t('referral.partner.stats.earningsAmount');
|
||||||
|
const resolvedComparisonLabel = comparisonLabel ?? t('referral.partner.stats.vsLastWeek');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bento-card">
|
||||||
|
<h4 className="mb-3 text-sm font-semibold text-dark-200">{resolvedTitle}</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{/* Count comparison */}
|
||||||
|
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||||
|
<div className="text-xs text-dark-500">{resolvedCountLabel}</div>
|
||||||
|
<div className="mt-1 flex items-baseline gap-2">
|
||||||
|
<span className="text-base font-semibold text-dark-100 sm:text-lg">
|
||||||
|
{data.current.referrals_count}
|
||||||
|
</span>
|
||||||
|
<TrendBadge
|
||||||
|
trend={data.referrals_change.trend}
|
||||||
|
percent={data.referrals_change.percent}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-dark-500">{resolvedComparisonLabel}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Earnings comparison */}
|
||||||
|
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||||
|
<div className="text-xs text-dark-500">{resolvedEarningsLabel}</div>
|
||||||
|
<div className="mt-1 flex items-baseline gap-2">
|
||||||
|
<span className="text-base font-semibold text-success-400 sm:text-lg">
|
||||||
|
{formatWithCurrency(data.current.earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||||
|
</span>
|
||||||
|
<TrendBadge trend={data.earnings_change.trend} percent={data.earnings_change.percent} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-dark-500">{resolvedComparisonLabel}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/components/stats/StatCard.tsx
Normal file
29
src/components/stats/StatCard.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
|
const DEFAULT_VALUE_CLASS = 'text-dark-100';
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
icon?: ReactNode;
|
||||||
|
valueClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
valueClassName = DEFAULT_VALUE_CLASS,
|
||||||
|
}: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl bg-dark-800/30 p-3">
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-dark-500">
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`mt-1 truncate text-base font-semibold sm:text-lg ${valueClassName}`}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/components/stats/constants.ts
Normal file
5
src/components/stats/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const TREND_STYLES = {
|
||||||
|
up: { arrow: '\u2191', className: 'text-success-400' },
|
||||||
|
down: { arrow: '\u2193', className: 'text-error-400' },
|
||||||
|
stable: { arrow: '\u2192', className: 'text-dark-400' },
|
||||||
|
} as const;
|
||||||
9
src/components/stats/index.ts
Normal file
9
src/components/stats/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { DailyChart } from './DailyChart';
|
||||||
|
export { PeriodComparison } from './PeriodComparison';
|
||||||
|
export { StatCard } from './StatCard';
|
||||||
|
export type {
|
||||||
|
DailyStatItem,
|
||||||
|
PeriodStats,
|
||||||
|
PeriodChange,
|
||||||
|
PeriodComparison as PeriodComparisonType,
|
||||||
|
} from './types';
|
||||||
24
src/components/stats/types.ts
Normal file
24
src/components/stats/types.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export interface DailyStatItem {
|
||||||
|
date: string;
|
||||||
|
referrals_count: number;
|
||||||
|
earnings_kopeks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeriodStats {
|
||||||
|
days: number;
|
||||||
|
referrals_count: number;
|
||||||
|
earnings_kopeks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeriodChange {
|
||||||
|
absolute: number;
|
||||||
|
percent: number;
|
||||||
|
trend: 'up' | 'down' | 'stable';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PeriodComparison {
|
||||||
|
current: PeriodStats;
|
||||||
|
previous: PeriodStats;
|
||||||
|
referrals_change: PeriodChange;
|
||||||
|
earnings_change: PeriodChange;
|
||||||
|
}
|
||||||
93
src/components/subscription/PurchaseCTAButton.tsx
Normal file
93
src/components/subscription/PurchaseCTAButton.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { HoverBorderGradient } from '../ui/hover-border-gradient';
|
||||||
|
import type { Subscription } from '../../types';
|
||||||
|
|
||||||
|
interface PurchaseCTAButtonProps {
|
||||||
|
subscription: Subscription | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PurchaseCTAButton({ subscription }: PurchaseCTAButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const isExpired = !subscription || (!subscription.is_active && !subscription.is_trial);
|
||||||
|
const isTrial = subscription?.is_trial;
|
||||||
|
|
||||||
|
const accentColor = isExpired ? '#FF3B5C' : '#3EDBB0';
|
||||||
|
|
||||||
|
const buttonText = isExpired
|
||||||
|
? t('subscription.getSubscription')
|
||||||
|
: isTrial
|
||||||
|
? t('subscription.trialUpgrade.title')
|
||||||
|
: t('subscription.extend');
|
||||||
|
|
||||||
|
const hintText = isExpired
|
||||||
|
? t('subscription.cta.expiredHint')
|
||||||
|
: isTrial
|
||||||
|
? t('subscription.cta.trialHint')
|
||||||
|
: t('subscription.cta.activeHint');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to="/subscription/purchase" className="block">
|
||||||
|
<HoverBorderGradient
|
||||||
|
accentColor={accentColor}
|
||||||
|
duration={4}
|
||||||
|
className="group relative w-full cursor-pointer overflow-hidden rounded-2xl"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative flex items-center justify-between rounded-[14px] px-5 py-4 transition-colors duration-300"
|
||||||
|
style={{
|
||||||
|
background: isExpired
|
||||||
|
? 'linear-gradient(135deg, rgba(255,59,92,0.08), rgba(255,107,53,0.06))'
|
||||||
|
: 'linear-gradient(135deg, rgba(62,219,176,0.08), rgba(0,229,160,0.06))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Left: icon + text */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Sparkle icon */}
|
||||||
|
<div
|
||||||
|
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl"
|
||||||
|
style={{
|
||||||
|
background: isExpired ? 'rgba(255,59,92,0.12)' : 'rgba(62,219,176,0.12)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke={accentColor}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[15px] font-semibold text-dark-50">{buttonText}</div>
|
||||||
|
<div className="text-[12px] text-dark-50/40">{hintText}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: chevron */}
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="flex-shrink-0 text-dark-50/30 transition-transform duration-300 group-hover:translate-x-1"
|
||||||
|
>
|
||||||
|
<path d="M9 18l6-6-6-6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</HoverBorderGradient>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,7 +45,8 @@ const baseClasses = `
|
|||||||
p-[var(--bento-padding)]
|
p-[var(--bento-padding)]
|
||||||
bg-dark-900/70
|
bg-dark-900/70
|
||||||
border border-dark-700/40
|
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 = `
|
const hoverClasses = `
|
||||||
|
|||||||
@@ -1,24 +1,31 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { safeBoolean } from './types';
|
||||||
|
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
|
|
||||||
export default function AuroraBackground({ settings }: Props) {
|
export default function AuroraBackground({ settings }: Props) {
|
||||||
const showRadialGradient = (settings.showRadialGradient as boolean) ?? true;
|
const showRadialGradient = safeBoolean(settings.showRadialGradient, true);
|
||||||
|
const paused = useAnimationPause();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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 &&
|
showRadialGradient &&
|
||||||
'[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,transparent_70%)]',
|
'[mask-image:radial-gradient(ellipse_at_100%_0%,black_10%,transparent_70%)]',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
backgroundImage:
|
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%)',
|
'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>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useRef, useState, useEffect } from 'react';
|
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
@@ -71,9 +72,13 @@ function CollisionMechanism({
|
|||||||
const [beamKey, setBeamKey] = useState(0);
|
const [beamKey, setBeamKey] = useState(0);
|
||||||
const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false);
|
const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const checkRef = useRef({ cycleDetected: false });
|
||||||
const checkCollision = () => {
|
checkRef.current.cycleDetected = cycleCollisionDetected;
|
||||||
if (beamRef.current && containerRef.current && parentRef.current && !cycleCollisionDetected) {
|
|
||||||
|
const checkCollision = useCallback(() => {
|
||||||
|
if (checkRef.current.cycleDetected) return;
|
||||||
|
if (!beamRef.current || !containerRef.current || !parentRef.current) return;
|
||||||
|
|
||||||
const beamRect = beamRef.current.getBoundingClientRect();
|
const beamRect = beamRef.current.getBoundingClientRect();
|
||||||
const containerRect = containerRef.current.getBoundingClientRect();
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
const parentRect = parentRef.current.getBoundingClientRect();
|
const parentRect = parentRef.current.getBoundingClientRect();
|
||||||
@@ -85,24 +90,42 @@ function CollisionMechanism({
|
|||||||
setCollision({ detected: true, coordinates: { x: relativeX, y: relativeY } });
|
setCollision({ detected: true, coordinates: { x: relativeX, y: relativeY } });
|
||||||
setCycleCollisionDetected(true);
|
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);
|
animId = requestAnimationFrame(loop);
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [cycleCollisionDetected, containerRef, parentRef]);
|
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (animId) cancelAnimationFrame(animId);
|
||||||
|
};
|
||||||
|
}, [checkCollision]);
|
||||||
|
|
||||||
|
// Collision reset with proper timeout cleanup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (collision.detected && collision.coordinates) {
|
if (!collision.detected || !collision.coordinates) return;
|
||||||
setTimeout(() => {
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
setCollision({ detected: false, coordinates: null });
|
setCollision({ detected: false, coordinates: null });
|
||||||
setCycleCollisionDetected(false);
|
setCycleCollisionDetected(false);
|
||||||
}, 2000);
|
|
||||||
setTimeout(() => {
|
|
||||||
setBeamKey((prev) => prev + 1);
|
setBeamKey((prev) => prev + 1);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
|
||||||
}, [collision]);
|
return () => clearTimeout(timer);
|
||||||
|
}, [collision.detected]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -154,10 +177,12 @@ function CollisionMechanism({
|
|||||||
export default function BackgroundBeamsCollision({ settings: _settings }: Props) {
|
export default function BackgroundBeamsCollision({ settings: _settings }: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const paused = useAnimationPause();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={parentRef} className="absolute inset-0 overflow-hidden">
|
<div ref={parentRef} className="absolute inset-0 overflow-hidden">
|
||||||
{BEAMS.map((beam) => (
|
{!paused &&
|
||||||
|
BEAMS.map((beam) => (
|
||||||
<CollisionMechanism
|
<CollisionMechanism
|
||||||
key={`${beam.initialX}-beam`}
|
key={`${beam.initialX}-beam`}
|
||||||
beamOptions={beam}
|
beamOptions={beam}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// All 50 paths from the original Aceternity component
|
||||||
const paths = [
|
const paths = [
|
||||||
'M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875',
|
'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',
|
'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-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-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-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)
|
// Concatenated static background path
|
||||||
const bgPath =
|
const bgPath = paths.join('');
|
||||||
'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';
|
|
||||||
|
// 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) {
|
export default React.memo(function BackgroundBeams({ settings: _settings }: Props) {
|
||||||
|
const paused = useAnimationPause();
|
||||||
|
|
||||||
|
// Inject CSS once on first render
|
||||||
|
React.useEffect(() => {
|
||||||
|
ensureStyles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden" style={{ contain: 'strict' }}>
|
||||||
<svg
|
<svg
|
||||||
className="pointer-events-none absolute inset-0 h-full w-full"
|
className="pointer-events-none absolute inset-0 h-full w-full"
|
||||||
width="100%"
|
width="100%"
|
||||||
@@ -43,55 +117,44 @@ export default React.memo(function BackgroundBeams({ settings: _settings }: Prop
|
|||||||
fill="none"
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
{/* Static background paths */}
|
{/* Static background — all paths at very low opacity */}
|
||||||
<path d={bgPath} stroke="url(#paint0_radial)" strokeOpacity="0.05" strokeWidth="0.5" />
|
<path
|
||||||
|
d={bgPath}
|
||||||
{/* Animated beam paths with moving gradients */}
|
stroke="url(#paint0_radial_beams)"
|
||||||
{paths.map((path, index) => (
|
strokeOpacity="0.05"
|
||||||
<motion.path
|
|
||||||
key={`path-${index}`}
|
|
||||||
d={path}
|
|
||||||
stroke={`url(#linearGradient-${index})`}
|
|
||||||
strokeOpacity="0.4"
|
|
||||||
strokeWidth="0.5"
|
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>
|
<defs>
|
||||||
{/* Animated linear gradients that move along the paths */}
|
{/* Single shared gradient for all beams (cyan → purple → magenta) */}
|
||||||
{paths.map((_, index) => (
|
<linearGradient id="beamGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
<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,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<stop stopColor="#18CCFC" stopOpacity="0" />
|
<stop stopColor="#18CCFC" stopOpacity="0" />
|
||||||
<stop stopColor="#18CCFC" />
|
<stop offset="10%" stopColor="#18CCFC" />
|
||||||
<stop offset="32.5%" stopColor="#6344F5" />
|
<stop offset="50%" stopColor="#6344F5" />
|
||||||
<stop offset="100%" stopColor="#AE48FF" stopOpacity="0" />
|
<stop offset="100%" stopColor="#AE48FF" stopOpacity="0" />
|
||||||
</motion.linearGradient>
|
</linearGradient>
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Radial gradient for static background */}
|
{/* Radial gradient for static background */}
|
||||||
<radialGradient
|
<radialGradient
|
||||||
id="paint0_radial"
|
id="paint0_radial_beams"
|
||||||
cx="0"
|
cx="0"
|
||||||
cy="0"
|
cy="0"
|
||||||
r="1"
|
r="1"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useEffect, useRef, useMemo } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
@@ -17,56 +17,143 @@ const COLORS = [
|
|||||||
'#c4b5fd',
|
'#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) {
|
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 rows = clampNumber(settings.rows, 4, 30, 15);
|
||||||
const cols = clampNumber(settings.cols, 4, 30, 15);
|
const cols = clampNumber(settings.cols, 4, 30, 15);
|
||||||
const boxColor = sanitizeColor(settings.boxColor, '#818cf8');
|
const boxColor = sanitizeColor(settings.boxColor, '#818cf8');
|
||||||
|
|
||||||
const cells = useMemo(() => {
|
const cells = useMemo((): CellData[] => {
|
||||||
const result: { color: string; delay: number; duration: number }[] = [];
|
return Array.from({ length: rows * cols }, () => ({
|
||||||
for (let i = 0; i < rows * cols; i++) {
|
rgb: hexToRgb(
|
||||||
result.push({
|
|
||||||
color:
|
|
||||||
boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor,
|
boxColor === '#818cf8' ? COLORS[Math.floor(Math.random() * COLORS.length)] : boxColor,
|
||||||
delay: Math.random() * 8,
|
),
|
||||||
duration: 3 + Math.random() * 4,
|
phase: Math.random() * 8,
|
||||||
});
|
period: 3 + Math.random() * 4,
|
||||||
}
|
}));
|
||||||
return result;
|
|
||||||
}, [rows, cols, boxColor]);
|
}, [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 (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<canvas
|
||||||
<div
|
ref={canvasRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '-100%',
|
inset: 0,
|
||||||
left: '-100%',
|
width: '100%',
|
||||||
width: '300%',
|
height: '100%',
|
||||||
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',
|
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
{cells.map((cell, i) => (
|
|
||||||
<motion.div
|
|
||||||
key={i}
|
|
||||||
className="border border-slate-700/50"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: [0, 0.15, 0] }}
|
|
||||||
transition={{
|
|
||||||
duration: cell.duration,
|
|
||||||
delay: cell.delay,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: 'easeInOut',
|
|
||||||
}}
|
|
||||||
style={{ backgroundColor: cell.color }}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { sanitizeColor } from './types';
|
import { sanitizeColor, safeBoolean, safeSelect } from './types';
|
||||||
|
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
|
|
||||||
function hexToRgbString(hex: string): string {
|
function hexToRgbString(hex: string): string {
|
||||||
hex = hex.replace('#', '');
|
hex = hex.replace('#', '');
|
||||||
if (hex.length === 3)
|
if (hex.length === 3)
|
||||||
@@ -22,23 +25,29 @@ function hexToRgbString(hex: string): string {
|
|||||||
|
|
||||||
export default function BackgroundGradientAnimation({ settings }: Props) {
|
export default function BackgroundGradientAnimation({ settings }: Props) {
|
||||||
const interactiveRef = useRef<HTMLDivElement>(null);
|
const interactiveRef = useRef<HTMLDivElement>(null);
|
||||||
|
const paused = useAnimationPause();
|
||||||
|
|
||||||
const firstColor = hexToRgbString(sanitizeColor(settings.firstColor, '#1271FF'));
|
const firstColor = hexToRgbString(sanitizeColor(settings.firstColor, '#1271FF'));
|
||||||
const secondColor = hexToRgbString(sanitizeColor(settings.secondColor, '#DD4AFF'));
|
const secondColor = hexToRgbString(sanitizeColor(settings.secondColor, '#DD4AFF'));
|
||||||
const thirdColor = hexToRgbString(sanitizeColor(settings.thirdColor, '#64DCFF'));
|
const thirdColor = hexToRgbString(sanitizeColor(settings.thirdColor, '#64DCFF'));
|
||||||
const fourthColor = hexToRgbString(sanitizeColor(settings.fourthColor, '#C83232'));
|
const fourthColor = hexToRgbString(sanitizeColor(settings.fourthColor, '#C83232'));
|
||||||
const fifthColor = hexToRgbString(sanitizeColor(settings.fifthColor, '#B4B432'));
|
const fifthColor = hexToRgbString(sanitizeColor(settings.fifthColor, '#B4B432'));
|
||||||
const interactive = (settings.interactive as boolean) ?? true;
|
const interactive = safeBoolean(settings.interactive, true);
|
||||||
const size = (settings.size as string) ?? '80%';
|
const size = safeSelect(settings.size, ['60%', '80%', '100%'] as const, '80%');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!interactive || !interactiveRef.current) return;
|
// Interactive mouse tracking — DESKTOP ONLY
|
||||||
|
if (!interactive || !interactiveRef.current || isMobile) return;
|
||||||
|
|
||||||
let curX = 0;
|
let curX = 0;
|
||||||
let curY = 0;
|
let curY = 0;
|
||||||
let tgX = 0;
|
let tgX = 0;
|
||||||
let tgY = 0;
|
let tgY = 0;
|
||||||
let animId = 0;
|
let animId = 0;
|
||||||
|
let docHidden = document.hidden;
|
||||||
|
let tgInactive = false;
|
||||||
|
|
||||||
|
const isHidden = () => docHidden || tgInactive;
|
||||||
|
|
||||||
const move = () => {
|
const move = () => {
|
||||||
curX += (tgX - curX) / 20;
|
curX += (tgX - curX) / 20;
|
||||||
@@ -49,20 +58,62 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
|||||||
animId = requestAnimationFrame(move);
|
animId = requestAnimationFrame(move);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (animId) return;
|
||||||
|
animId = requestAnimationFrame(move);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (animId) {
|
||||||
|
cancelAnimationFrame(animId);
|
||||||
|
animId = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleMouse = (e: MouseEvent) => {
|
const handleMouse = (e: MouseEvent) => {
|
||||||
tgX = e.clientX;
|
tgX = e.clientX;
|
||||||
tgY = e.clientY;
|
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);
|
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 () => {
|
return () => {
|
||||||
|
stop();
|
||||||
window.removeEventListener('mousemove', handleMouse);
|
window.removeEventListener('mousemove', handleMouse);
|
||||||
cancelAnimationFrame(animId);
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
if (tg) {
|
||||||
|
tg.offEvent?.('activated', onActivated);
|
||||||
|
tg.offEvent?.('deactivated', onDeactivated);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [interactive]);
|
}, [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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 overflow-hidden"
|
className="absolute inset-0 overflow-hidden"
|
||||||
@@ -74,11 +125,12 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
|||||||
'--fourth-color': fourthColor,
|
'--fourth-color': fourthColor,
|
||||||
'--fifth-color': fifthColor,
|
'--fifth-color': fifthColor,
|
||||||
'--size': size,
|
'--size': size,
|
||||||
'--blending': 'hard-light',
|
|
||||||
background: `linear-gradient(40deg, rgb(${firstColor}), rgb(${fifthColor}))`,
|
background: `linear-gradient(40deg, rgb(${firstColor}), rgb(${fifthColor}))`,
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{/* SVG filter only rendered on desktop */}
|
||||||
|
{!isMobile && (
|
||||||
<svg className="hidden">
|
<svg className="hidden">
|
||||||
<defs>
|
<defs>
|
||||||
<filter id="blurMe">
|
<filter id="blurMe">
|
||||||
@@ -93,7 +145,8 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
|||||||
</filter>
|
</filter>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</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: firstColor, anim: 'animate-move-vertical' },
|
||||||
{ color: secondColor, anim: 'animate-move-in-circle' },
|
{ color: secondColor, anim: 'animate-move-in-circle' },
|
||||||
@@ -104,15 +157,17 @@ export default function BackgroundGradientAnimation({ settings }: Props) {
|
|||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={cn(
|
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,
|
blob.anim,
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
background: `radial-gradient(circle at center, rgba(${blob.color}, 0.8) 0, rgba(${blob.color}, 0) 50%) no-repeat`,
|
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
|
<div
|
||||||
ref={interactiveRef}
|
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"
|
className="absolute left-[calc(50%-var(--size)/2)] top-[calc(50%-var(--size)/2)] h-[var(--size)] w-[var(--size)] rounded-full opacity-70 mix-blend-hard-light"
|
||||||
|
|||||||
@@ -1,50 +1,93 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { createNoise3D } from 'simplex-noise';
|
import { createNoise3D } from 'simplex-noise';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
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) {
|
export default function BackgroundLines({ settings }: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
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 lineCount = clampNumber(settings.lineCount, 5, 100, 40);
|
||||||
const lineColor = sanitizeColor(settings.lineColor, '#818cf8');
|
const lineColor = sanitizeColor(settings.lineColor, '#818cf8');
|
||||||
const speed = clampNumber(settings.speed, 0.0005, 0.01, 0.002);
|
const speed = clampNumber(settings.speed, 0.0005, 0.01, 0.002);
|
||||||
const strokeWidth = clampNumber(settings.strokeWidth, 0.5, 5, 1);
|
const strokeWidth = clampNumber(settings.strokeWidth, 0.5, 5, 1);
|
||||||
|
|
||||||
|
const effectiveLineCount = isMobile ? Math.min(lineCount, 20) : lineCount;
|
||||||
|
const step = isMobile ? 8 : 4;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
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');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
const noise3D = createNoise3D();
|
stateRef.current = { ctx, noise3D: createNoise3D(), nt: 0, w, h, dpr };
|
||||||
let nt = 0;
|
|
||||||
|
|
||||||
const resize = () => {
|
const onResize = () => {
|
||||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
const nw = parent?.offsetWidth ?? window.innerWidth;
|
||||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
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 = () => {
|
window.addEventListener('resize', onResize);
|
||||||
nt += speed;
|
return () => window.removeEventListener('resize', onResize);
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
}, []);
|
||||||
|
|
||||||
for (let i = 0; i < lineCount; i++) {
|
useAnimationLoop(() => {
|
||||||
const yBase = (i / lineCount) * canvas.height;
|
const state = stateRef.current;
|
||||||
const opacity = 0.1 + 0.15 * Math.sin((i / lineCount) * Math.PI);
|
if (!state) return;
|
||||||
|
|
||||||
|
const { ctx, noise3D, w, h } = state;
|
||||||
|
state.nt += speed;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.strokeStyle = lineColor;
|
ctx.strokeStyle = lineColor;
|
||||||
ctx.globalAlpha = opacity;
|
|
||||||
ctx.lineWidth = strokeWidth;
|
ctx.lineWidth = strokeWidth;
|
||||||
|
|
||||||
for (let x = 0; x < canvas.width; x += 4) {
|
for (let i = 0; i < effectiveLineCount; i++) {
|
||||||
const y = yBase + noise3D(x / 600, i * 0.3, nt) * 40;
|
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) {
|
if (x === 0) {
|
||||||
ctx.moveTo(x, y);
|
ctx.moveTo(x, y);
|
||||||
} else {
|
} else {
|
||||||
@@ -56,18 +99,7 @@ export default function BackgroundLines({ settings }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
}, [effectiveLineCount, lineColor, speed, strokeWidth]);
|
||||||
};
|
|
||||||
|
|
||||||
resize();
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
window.removeEventListener('resize', resize);
|
|
||||||
};
|
|
||||||
}, [lineCount, lineColor, speed, strokeWidth]);
|
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RippleState {
|
||||||
|
ctx: CanvasRenderingContext2D;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
dpr: number;
|
||||||
|
ripples: { phase: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function BackgroundRipple({ settings }: Props) {
|
export default function BackgroundRipple({ settings }: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const animationRef = useRef<number>(0);
|
const stateRef = useRef<RippleState | null>(null);
|
||||||
|
|
||||||
const rippleColor = sanitizeColor(settings.rippleColor, '#818cf8');
|
const rippleColor = sanitizeColor(settings.rippleColor, '#818cf8');
|
||||||
const rippleCount = clampNumber(settings.rippleCount, 1, 20, 5);
|
const rippleCount = clampNumber(settings.rippleCount, 1, 20, 5);
|
||||||
@@ -17,24 +26,53 @@ export default function BackgroundRipple({ settings }: Props) {
|
|||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
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');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
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) => ({
|
const ripples = Array.from({ length: rippleCount }, (_, i) => ({
|
||||||
phase: (i / rippleCount) * Math.PI * 2,
|
phase: (i / rippleCount) * Math.PI * 2,
|
||||||
maxRadius: 0,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const animate = (time: number) => {
|
stateRef.current = { ctx, w, h, dpr, ripples };
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
const cx = canvas.width / 2;
|
const onResize = () => {
|
||||||
const cy = canvas.height / 2;
|
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);
|
const maxR = Math.sqrt(cx * cx + cy * cy);
|
||||||
|
|
||||||
for (let i = 0; i < ripples.length; i++) {
|
for (let i = 0; i < ripples.length; i++) {
|
||||||
@@ -52,18 +90,9 @@ export default function BackgroundRipple({ settings }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.globalAlpha = 1;
|
ctx.globalAlpha = 1;
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
},
|
||||||
};
|
[rippleColor, rippleCount, speed],
|
||||||
|
);
|
||||||
resize();
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
window.removeEventListener('resize', resize);
|
|
||||||
};
|
|
||||||
}, [rippleColor, rippleCount, speed]);
|
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber, safeSelect } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function GridBackground({ settings }: Props) {
|
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 gridColor = sanitizeColor(settings.gridColor, 'rgba(255,255,255,0.05)');
|
||||||
const gridSize = clampNumber(settings.gridSize, 10, 200, 40);
|
const gridSize = clampNumber(settings.gridSize, 10, 200, 40);
|
||||||
const dotSize = clampNumber(settings.dotSize, 0.5, 10, 1.5);
|
const dotSize = clampNumber(settings.dotSize, 0.5, 10, 1.5);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
@@ -8,6 +9,7 @@ interface Props {
|
|||||||
export default function Meteors({ settings }: Props) {
|
export default function Meteors({ settings }: Props) {
|
||||||
const count = clampNumber(settings.count, 1, 50, 20);
|
const count = clampNumber(settings.count, 1, 50, 20);
|
||||||
const meteorColor = sanitizeColor(settings.meteorColor, '#ffffff');
|
const meteorColor = sanitizeColor(settings.meteorColor, '#ffffff');
|
||||||
|
const paused = useAnimationPause();
|
||||||
|
|
||||||
const meteors = useMemo(
|
const meteors = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -31,6 +33,7 @@ export default function Meteors({ settings }: Props) {
|
|||||||
left: meteor.left,
|
left: meteor.left,
|
||||||
animationDelay: meteor.delay,
|
animationDelay: meteor.delay,
|
||||||
animationDuration: meteor.duration,
|
animationDuration: meteor.duration,
|
||||||
|
animationPlayState: paused ? 'paused' : 'running',
|
||||||
width: meteor.size,
|
width: meteor.size,
|
||||||
height: 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`,
|
boxShadow: `0 0 0 1px rgba(255,255,255,0.05), 0 0 2px 1px ${meteorColor}20, 0 0 20px 2px ${meteorColor}40`,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
@@ -23,9 +24,20 @@ interface BgStar {
|
|||||||
twinkleSpeed: number | null;
|
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) {
|
export default function ShootingStarsBackground({ settings }: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const animationRef = useRef<number>(0);
|
const stateRef = useRef<ShootingState | null>(null);
|
||||||
|
|
||||||
const starColor = sanitizeColor(settings.starColor, '#9E00FF');
|
const starColor = sanitizeColor(settings.starColor, '#9E00FF');
|
||||||
const trailColor = sanitizeColor(settings.trailColor, '#2EB9DF');
|
const trailColor = sanitizeColor(settings.trailColor, '#2EB9DF');
|
||||||
@@ -37,46 +49,67 @@ export default function ShootingStarsBackground({ settings }: Props) {
|
|||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
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');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
let shootingStars: Star[] = [];
|
const count = Math.floor(w * h * starDensity);
|
||||||
let bgStars: BgStar[] = [];
|
const bgStars: BgStar[] = Array.from({ length: count }, () => ({
|
||||||
let lastShootingTime = 0;
|
x: Math.random() * w,
|
||||||
|
y: Math.random() * h,
|
||||||
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,
|
|
||||||
radius: Math.random() * 1.2 + 0.3,
|
radius: Math.random() * 1.2 + 0.3,
|
||||||
opacity: Math.random(),
|
opacity: Math.random(),
|
||||||
twinkleSpeed: Math.random() > 0.3 ? 0.5 + Math.random() * 0.5 : null,
|
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 = () => {
|
const onResize = () => {
|
||||||
shootingStars.push({
|
const nw = parent?.offsetWidth ?? window.innerWidth;
|
||||||
x: Math.random() * canvas.width,
|
const nh = parent?.offsetHeight ?? window.innerHeight;
|
||||||
y: Math.random() * canvas.height * 0.5,
|
canvas.width = nw * dpr;
|
||||||
angle: Math.PI / 4 + (Math.random() - 0.5) * 0.3,
|
canvas.height = nh * dpr;
|
||||||
scale: 0.5 + Math.random() * 0.5,
|
canvas.style.width = `${nw}px`;
|
||||||
speed: minSpeed + Math.random() * (maxSpeed - minSpeed),
|
canvas.style.height = `${nh}px`;
|
||||||
distance: 0,
|
if (stateRef.current) {
|
||||||
opacity: 1,
|
stateRef.current.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
});
|
stateRef.current.w = nw;
|
||||||
|
stateRef.current.h = nh;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const animate = (time: number) => {
|
window.addEventListener('resize', onResize);
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
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) {
|
for (const s of bgStars) {
|
||||||
let opacity = s.opacity;
|
let opacity = s.opacity;
|
||||||
if (s.twinkleSpeed) {
|
if (s.twinkleSpeed) {
|
||||||
@@ -88,14 +121,21 @@ export default function ShootingStarsBackground({ settings }: Props) {
|
|||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spawn shooting stars
|
if (time - state.lastShootingTime > state.nextShootingDelay) {
|
||||||
if (time - lastShootingTime > 4200 + Math.random() * 4500) {
|
state.shootingStars.push({
|
||||||
spawnShootingStar();
|
x: Math.random() * w,
|
||||||
lastShootingTime = time;
|
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
|
state.shootingStars = state.shootingStars.filter((star) => {
|
||||||
shootingStars = shootingStars.filter((star) => {
|
|
||||||
star.distance += star.speed;
|
star.distance += star.speed;
|
||||||
star.opacity = Math.max(0, 1 - star.distance / 500);
|
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 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 tailY = star.y + Math.sin(star.angle) * Math.max(0, star.distance - 80);
|
||||||
|
|
||||||
const gradient = ctx.createLinearGradient(tailX, tailY, x2, y2);
|
// Draw trail with flat color (avoids per-frame gradient allocation)
|
||||||
gradient.addColorStop(0, 'transparent');
|
|
||||||
gradient.addColorStop(0.5, trailColor);
|
|
||||||
gradient.addColorStop(1, starColor);
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.strokeStyle = gradient;
|
|
||||||
ctx.lineWidth = star.scale * 2;
|
ctx.lineWidth = star.scale * 2;
|
||||||
ctx.globalAlpha = star.opacity;
|
ctx.globalAlpha = star.opacity * 0.4;
|
||||||
|
ctx.strokeStyle = trailColor;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(tailX, tailY);
|
ctx.moveTo(tailX, tailY);
|
||||||
ctx.lineTo(x2, y2);
|
ctx.lineTo(x2, y2);
|
||||||
ctx.stroke();
|
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;
|
return true;
|
||||||
});
|
});
|
||||||
|
},
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
[starColor, trailColor, starDensity, minSpeed, maxSpeed],
|
||||||
};
|
);
|
||||||
|
|
||||||
resize();
|
|
||||||
animationRef.current = requestAnimationFrame(animate);
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelAnimationFrame(animationRef.current);
|
|
||||||
window.removeEventListener('resize', resize);
|
|
||||||
};
|
|
||||||
}, [starColor, trailColor, starDensity, minSpeed, maxSpeed]);
|
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
@@ -16,18 +17,16 @@ interface Particle {
|
|||||||
opacitySpeed: number;
|
opacitySpeed: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sparkles({ settings }: Props) {
|
interface SparklesState {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
ctx: CanvasRenderingContext2D;
|
||||||
const animationRef = useRef<number>(0);
|
particles: Particle[];
|
||||||
const particlesRef = useRef<Particle[]>([]);
|
rgb: { r: number; g: number; b: number };
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
dpr: number;
|
||||||
|
}
|
||||||
|
|
||||||
const particleDensity = clampNumber(settings.particleDensity, 50, 5000, 800);
|
function hexToRgb(hex: string) {
|
||||||
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) => {
|
|
||||||
hex = hex.replace('#', '');
|
hex = hex.replace('#', '');
|
||||||
if (hex.length === 3)
|
if (hex.length === 3)
|
||||||
hex = hex
|
hex = hex
|
||||||
@@ -39,27 +38,40 @@ export default function Sparkles({ settings }: Props) {
|
|||||||
g: parseInt(hex.substring(2, 4), 16),
|
g: parseInt(hex.substring(2, 4), 16),
|
||||||
b: parseInt(hex.substring(4, 6), 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(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
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');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
const resize = () => {
|
const area = w * h;
|
||||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
|
||||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
|
||||||
initParticles();
|
|
||||||
};
|
|
||||||
|
|
||||||
const initParticles = () => {
|
|
||||||
const area = canvas.width * canvas.height;
|
|
||||||
const count = Math.floor((area / 1000000) * particleDensity);
|
const count = Math.floor((area / 1000000) * particleDensity);
|
||||||
particlesRef.current = Array.from({ length: count }, () => ({
|
const particles: Particle[] = Array.from({ length: count }, () => ({
|
||||||
x: Math.random() * canvas.width,
|
x: Math.random() * w,
|
||||||
y: Math.random() * canvas.height,
|
y: Math.random() * h,
|
||||||
size: minSize + Math.random() * (maxSize - minSize),
|
size: minSize + Math.random() * (maxSize - minSize),
|
||||||
speedX: (Math.random() - 0.5) * speed * 0.2,
|
speedX: (Math.random() - 0.5) * speed * 0.2,
|
||||||
speedY: (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,
|
opacityDirection: Math.random() > 0.5 ? 1 : -1,
|
||||||
opacitySpeed: 0.005 + Math.random() * 0.01 * speed,
|
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 = () => {
|
useAnimationLoop(() => {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
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.x += p.speedX;
|
||||||
p.y += p.speedY;
|
p.y += p.speedY;
|
||||||
p.opacity += p.opacityDirection * p.opacitySpeed;
|
p.opacity += p.opacityDirection * p.opacitySpeed;
|
||||||
@@ -87,29 +121,17 @@ export default function Sparkles({ settings }: Props) {
|
|||||||
p.opacityDirection = -1;
|
p.opacityDirection = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (p.x < 0) p.x = canvas.width;
|
if (p.x < 0) p.x = w;
|
||||||
if (p.x > canvas.width) p.x = 0;
|
if (p.x > w) p.x = 0;
|
||||||
if (p.y < 0) p.y = canvas.height;
|
if (p.y < 0) p.y = h;
|
||||||
if (p.y > canvas.height) p.y = 0;
|
if (p.y > h) p.y = 0;
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${p.opacity})`;
|
ctx.fillStyle = `rgba(${rgb.r},${rgb.g},${rgb.b},${p.opacity})`;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
}, [particleDensity, minSize, maxSize, speed, particleColor]);
|
||||||
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]);
|
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationPause } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
settings: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
|
|
||||||
export default function SpotlightBg({ settings }: Props) {
|
export default function SpotlightBg({ settings }: Props) {
|
||||||
const spotlightColor = sanitizeColor(settings.spotlightColor, '#818cf8');
|
const spotlightColor = sanitizeColor(settings.spotlightColor, '#818cf8');
|
||||||
const spotlightSize = clampNumber(settings.spotlightSize, 100, 1000, 400);
|
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 (
|
return (
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className="absolute inset-0 overflow-hidden">
|
||||||
@@ -27,7 +38,7 @@ export default function SpotlightBg({ settings }: Props) {
|
|||||||
height: spotlightSize,
|
height: spotlightSize,
|
||||||
background: `radial-gradient(circle, ${spotlightColor}40 0%, transparent 70%)`,
|
background: `radial-gradient(circle, ${spotlightColor}40 0%, transparent 70%)`,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
filter: 'blur(60px)',
|
filter: `blur(${blur1}px)`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -46,7 +57,7 @@ export default function SpotlightBg({ settings }: Props) {
|
|||||||
height: spotlightSize * 0.8,
|
height: spotlightSize * 0.8,
|
||||||
background: `radial-gradient(circle, ${spotlightColor}30 0%, transparent 70%)`,
|
background: `radial-gradient(circle, ${spotlightColor}30 0%, transparent 70%)`,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
filter: 'blur(80px)',
|
filter: `blur(${blur2}px)`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -42,17 +42,30 @@ export function sanitizeColor(value: unknown, fallback: string): string {
|
|||||||
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
if (/^#[0-9a-fA-F]{3,8}$/.test(trimmed)) return trimmed;
|
||||||
// Allow rgb/rgba/hsl/hsla with numbers, commas, spaces, dots, %
|
// Allow rgb/rgba/hsl/hsla with numbers, commas, spaces, dots, %
|
||||||
if (/^(rgb|hsl)a?\([0-9,.\s/%]+\)$/.test(trimmed)) return trimmed;
|
if (/^(rgb|hsl)a?\([0-9,.\s/%]+\)$/.test(trimmed)) return trimmed;
|
||||||
// Allow CSS named colors (alphanumeric only)
|
// Allow CSS named colors (alphanumeric only, exclude CSS-wide keywords)
|
||||||
if (/^[a-zA-Z]{3,30}$/.test(trimmed)) return trimmed;
|
if (/^[a-zA-Z]{3,30}$/.test(trimmed) && !CSS_WIDE_KEYWORDS.has(trimmed.toLowerCase()))
|
||||||
|
return trimmed;
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clamp a numeric value within bounds */
|
/** Clamp a numeric value within bounds */
|
||||||
export function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
|
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));
|
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 {
|
export interface SettingDefinition {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -1,14 +1,37 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { createNoise3D } from 'simplex-noise';
|
import { createNoise3D } from 'simplex-noise';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber } from './types';
|
||||||
|
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
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) {
|
export default function VortexBackground({ settings }: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
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 particleCount = clampNumber(settings.particleCount, 50, 2000, 500);
|
||||||
const rangeY = clampNumber(settings.rangeY, 10, 500, 100);
|
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 rangeSpeed = clampNumber(settings.rangeSpeed, 0.1, 5, 1.5);
|
||||||
const backgroundColor = sanitizeColor(settings.backgroundColor, '#000000');
|
const backgroundColor = sanitizeColor(settings.backgroundColor, '#000000');
|
||||||
|
|
||||||
|
const particlePropCount = 9;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
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');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
|
||||||
const noise3D = createNoise3D();
|
const noise3D = createNoise3D();
|
||||||
const particlePropCount = 9;
|
|
||||||
const particlePropsLength = particleCount * particlePropCount;
|
const particlePropsLength = particleCount * particlePropCount;
|
||||||
let particleProps = new Float32Array(particlePropsLength);
|
const particleProps = new Float32Array(particlePropsLength);
|
||||||
let tick = 0;
|
const center: [number, number] = [0.5 * w, 0.5 * h];
|
||||||
let center: [number, number] = [0, 0];
|
|
||||||
|
|
||||||
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) {
|
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 = (
|
window.addEventListener('resize', onResize);
|
||||||
x: number,
|
return () => window.removeEventListener('resize', onResize);
|
||||||
y: number,
|
}, [particleCount, rangeY, baseHue, rangeSpeed]);
|
||||||
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();
|
|
||||||
};
|
|
||||||
|
|
||||||
const draw = () => {
|
useAnimationLoop(() => {
|
||||||
tick++;
|
const state = stateRef.current;
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
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.fillStyle = backgroundColor;
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
|
for (let i = 0; i < particlePropsLength; i += particlePropCount) {
|
||||||
const x = particleProps[i];
|
const x = particleProps[i];
|
||||||
const y = particleProps[i + 1];
|
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 vx = lerp(particleProps[i + 2], Math.cos(n), 0.5);
|
||||||
const vy = lerp(particleProps[i + 3], Math.sin(n), 0.5);
|
const vy = lerp(particleProps[i + 3], Math.sin(n), 0.5);
|
||||||
const life = particleProps[i + 4];
|
const life = particleProps[i + 4];
|
||||||
@@ -106,7 +125,12 @@ export default function VortexBackground({ settings }: Props) {
|
|||||||
const radius = particleProps[i + 7];
|
const radius = particleProps[i + 7];
|
||||||
const hue = particleProps[i + 8];
|
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] = x2;
|
||||||
particleProps[i + 1] = y2;
|
particleProps[i + 1] = y2;
|
||||||
@@ -114,30 +138,28 @@ export default function VortexBackground({ settings }: Props) {
|
|||||||
particleProps[i + 3] = vy;
|
particleProps[i + 3] = vy;
|
||||||
particleProps[i + 4] = life + 1;
|
particleProps[i + 4] = life + 1;
|
||||||
|
|
||||||
if (x2 > canvas.width || x2 < 0 || y2 > canvas.height || y2 < 0 || life + 1 > ttl) {
|
if (x2 > w || x2 < 0 || y2 > h || y2 < 0 || life + 1 > ttl) {
|
||||||
initParticle(i);
|
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.save();
|
||||||
ctx.filter = 'blur(8px) brightness(200%)';
|
ctx.filter = 'blur(8px) brightness(200%)';
|
||||||
ctx.globalCompositeOperation = 'lighter';
|
ctx.globalCompositeOperation = 'lighter';
|
||||||
ctx.drawImage(canvas, 0, 0);
|
const canvas = canvasRef.current;
|
||||||
|
if (canvas) ctx.drawImage(canvas, 0, 0, w, h);
|
||||||
ctx.restore();
|
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]);
|
}, [particleCount, rangeY, baseHue, rangeSpeed, backgroundColor]);
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
|
|||||||
@@ -1,16 +1,28 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { createNoise3D } from 'simplex-noise';
|
import { createNoise3D } from 'simplex-noise';
|
||||||
import { sanitizeColor, clampNumber } from './types';
|
import { sanitizeColor, clampNumber, safeSelect } from './types';
|
||||||
|
import { useAnimationLoop, getMobileDpr } from '@/hooks/useAnimationLoop';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
settings: Record<string, unknown>;
|
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) {
|
export default function WavyBackground({ settings }: Props) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
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 waveWidth = clampNumber(settings.waveWidth, 5, 200, 50);
|
||||||
const blur = clampNumber(settings.blur, 0, 50, 10);
|
const blur = clampNumber(settings.blur, 0, 50, 10);
|
||||||
const waveOpacity = clampNumber(settings.waveOpacity, 0.05, 1, 0.5);
|
const waveOpacity = clampNumber(settings.waveOpacity, 0.05, 1, 0.5);
|
||||||
@@ -21,55 +33,73 @@ export default function WavyBackground({ settings }: Props) {
|
|||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
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');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
const noise3D = createNoise3D();
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
let nt = 0;
|
const effectiveBlur = isMobile ? Math.min(blur, 4) : blur;
|
||||||
const speedVal = speed === 'slow' ? 0.001 : 0.002;
|
ctx.filter = effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : 'none';
|
||||||
const waveCount = 5;
|
|
||||||
|
|
||||||
const resize = () => {
|
stateRef.current = { ctx, noise3D: createNoise3D(), nt: 0, w, h, dpr };
|
||||||
canvas.width = canvas.parentElement?.offsetWidth ?? window.innerWidth;
|
|
||||||
canvas.height = canvas.parentElement?.offsetHeight ?? window.innerHeight;
|
const onResize = () => {
|
||||||
ctx.filter = `blur(${blur}px)`;
|
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) => {
|
window.addEventListener('resize', onResize);
|
||||||
nt += speedVal;
|
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++) {
|
for (let i = 0; i < waveCount; i++) {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.lineWidth = waveWidth;
|
ctx.lineWidth = waveWidth;
|
||||||
ctx.strokeStyle = colors[i % colors.length];
|
ctx.strokeStyle = colors[i % colors.length];
|
||||||
ctx.globalAlpha = waveOpacity;
|
ctx.globalAlpha = waveOpacity;
|
||||||
|
|
||||||
for (let x = 0; x < canvas.width; x += 5) {
|
for (let x = 0; x < w; x += step) {
|
||||||
const y = noise3D(x / 800, 0.3 * i, nt) * 100;
|
const y = noise3D(x / 800, 0.3 * i, state.nt) * 100;
|
||||||
ctx.lineTo(x, y + canvas.height * 0.5);
|
ctx.lineTo(x, y + h * 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.closePath();
|
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]);
|
}, [speed, waveWidth, blur, waveOpacity, backgroundFill]);
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
return <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" />;
|
||||||
|
|||||||
22
src/constants/charts.ts
Normal file
22
src/constants/charts.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export const CHART_COMMON = {
|
||||||
|
KOPEKS_DIVISOR: 100,
|
||||||
|
GRADIENT: {
|
||||||
|
START_OFFSET: '5%',
|
||||||
|
END_OFFSET: '95%',
|
||||||
|
START_OPACITY: 0.3,
|
||||||
|
END_OPACITY: 0,
|
||||||
|
},
|
||||||
|
AXIS: {
|
||||||
|
TICK_FONT_SIZE: 11,
|
||||||
|
},
|
||||||
|
TOOLTIP: {
|
||||||
|
BORDER_RADIUS: '8px',
|
||||||
|
FONT_SIZE: '12px',
|
||||||
|
},
|
||||||
|
STROKE_WIDTH: 2,
|
||||||
|
GRID_DASH: '3 3',
|
||||||
|
STALE_TIME: 60_000,
|
||||||
|
CHART: {
|
||||||
|
MARGIN: { top: 5, right: 10, left: 0, bottom: 5 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
33
src/constants/partner.ts
Normal file
33
src/constants/partner.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { CHART_COMMON } from './charts';
|
||||||
|
|
||||||
|
export const PARTNER_STATS = {
|
||||||
|
/** Chart visual configuration */
|
||||||
|
CHART: {
|
||||||
|
...CHART_COMMON.CHART,
|
||||||
|
HEIGHT: 200,
|
||||||
|
},
|
||||||
|
/** Kopeks-to-currency divisor */
|
||||||
|
KOPEKS_DIVISOR: CHART_COMMON.KOPEKS_DIVISOR,
|
||||||
|
/** Copy feedback duration in ms */
|
||||||
|
COPY_FEEDBACK_MS: 2000,
|
||||||
|
/** Gradient stop offsets */
|
||||||
|
GRADIENT: CHART_COMMON.GRADIENT,
|
||||||
|
/** Axis tick styling */
|
||||||
|
AXIS: {
|
||||||
|
TICK_FONT_SIZE: CHART_COMMON.AXIS.TICK_FONT_SIZE,
|
||||||
|
EARNINGS_WIDTH: 45,
|
||||||
|
REFERRALS_WIDTH: 30,
|
||||||
|
},
|
||||||
|
/** Tooltip styling */
|
||||||
|
TOOLTIP: CHART_COMMON.TOOLTIP,
|
||||||
|
/** Area stroke width */
|
||||||
|
STROKE_WIDTH: CHART_COMMON.STROKE_WIDTH,
|
||||||
|
/** Grid dash array */
|
||||||
|
GRID_DASH: CHART_COMMON.GRID_DASH,
|
||||||
|
/** Skeleton count for loading state */
|
||||||
|
SKELETON_COUNT: 3,
|
||||||
|
/** Maximum conversion rate for progress bar */
|
||||||
|
MAX_CONVERSION_RATE: 100,
|
||||||
|
/** Stale time for campaign stats query (ms) */
|
||||||
|
STATS_STALE_TIME: CHART_COMMON.STALE_TIME,
|
||||||
|
} as const;
|
||||||
39
src/constants/salesStats.ts
Normal file
39
src/constants/salesStats.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { CHART_COMMON } from './charts';
|
||||||
|
|
||||||
|
export const SALES_STATS = {
|
||||||
|
CHART: {
|
||||||
|
...CHART_COMMON.CHART,
|
||||||
|
HEIGHT: 220,
|
||||||
|
PIE_HEIGHT: 250,
|
||||||
|
},
|
||||||
|
KOPEKS_DIVISOR: CHART_COMMON.KOPEKS_DIVISOR,
|
||||||
|
GRADIENT: CHART_COMMON.GRADIENT,
|
||||||
|
AXIS: {
|
||||||
|
TICK_FONT_SIZE: CHART_COMMON.AXIS.TICK_FONT_SIZE,
|
||||||
|
WIDTH: 40,
|
||||||
|
},
|
||||||
|
TOOLTIP: CHART_COMMON.TOOLTIP,
|
||||||
|
STROKE_WIDTH: CHART_COMMON.STROKE_WIDTH,
|
||||||
|
GRID_DASH: CHART_COMMON.GRID_DASH,
|
||||||
|
STALE_TIME: CHART_COMMON.STALE_TIME,
|
||||||
|
DEFAULT_PERIOD: 30,
|
||||||
|
PERIOD_PRESETS: [7, 30, 90, 0] as const,
|
||||||
|
PROVIDER_COLORS: {
|
||||||
|
telegram: '#229ED9',
|
||||||
|
email: '#34d399',
|
||||||
|
vk: '#4C75A3',
|
||||||
|
yandex: '#FC3F1D',
|
||||||
|
google: '#4285F4',
|
||||||
|
discord: '#5865F2',
|
||||||
|
} as const,
|
||||||
|
BAR_COLORS: [
|
||||||
|
'#34d399',
|
||||||
|
'#818cf8',
|
||||||
|
'#f59e0b',
|
||||||
|
'#ec4899',
|
||||||
|
'#06b6d4',
|
||||||
|
'#8b5cf6',
|
||||||
|
'#f97316',
|
||||||
|
'#14b8a6',
|
||||||
|
] as const,
|
||||||
|
} as const;
|
||||||
140
src/hooks/useAnimationLoop.ts
Normal file
140
src/hooks/useAnimationLoop.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||||
|
const TARGET_FPS = isMobile ? 30 : 60;
|
||||||
|
const FRAME_INTERVAL = 1000 / TARGET_FPS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared animation loop hook with built-in performance safeguards:
|
||||||
|
* - FPS throttling: 30 FPS on mobile, 60 FPS on desktop
|
||||||
|
* - Page Visibility API: fully stops RAF when tab/app is hidden
|
||||||
|
* - Telegram Mini App: fully stops RAF when isActive = false
|
||||||
|
* - Auto-cleanup on unmount
|
||||||
|
*/
|
||||||
|
export function useAnimationLoop(
|
||||||
|
callback: (time: number, delta: number) => void,
|
||||||
|
deps: React.DependencyList,
|
||||||
|
) {
|
||||||
|
const callbackRef = useRef(callback);
|
||||||
|
callbackRef.current = callback;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let animId = 0;
|
||||||
|
let lastTime = 0;
|
||||||
|
let docHidden = document.hidden;
|
||||||
|
let tgInactive = false;
|
||||||
|
|
||||||
|
const isPaused = () => docHidden || tgInactive;
|
||||||
|
|
||||||
|
const loop = (time: number) => {
|
||||||
|
const delta = time - lastTime;
|
||||||
|
if (delta >= FRAME_INTERVAL) {
|
||||||
|
lastTime = time - (delta % FRAME_INTERVAL);
|
||||||
|
callbackRef.current(time, delta);
|
||||||
|
}
|
||||||
|
animId = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
if (animId) return; // already running
|
||||||
|
lastTime = performance.now();
|
||||||
|
animId = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (animId) {
|
||||||
|
cancelAnimationFrame(animId);
|
||||||
|
animId = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
docHidden = document.hidden;
|
||||||
|
if (isPaused()) stop();
|
||||||
|
else start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
const onTgActivated = () => {
|
||||||
|
tgInactive = false;
|
||||||
|
if (!isPaused()) start();
|
||||||
|
};
|
||||||
|
const onTgDeactivated = () => {
|
||||||
|
tgInactive = true;
|
||||||
|
stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
if (tg) {
|
||||||
|
tg.onEvent?.('activated', onTgActivated);
|
||||||
|
tg.onEvent?.('deactivated', onTgDeactivated);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the loop (only if page is currently visible)
|
||||||
|
if (!isPaused()) start();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop();
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
if (tg) {
|
||||||
|
tg.offEvent?.('activated', onTgActivated);
|
||||||
|
tg.offEvent?.('deactivated', onTgDeactivated);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether animations should be paused (tab hidden or Telegram deactivated).
|
||||||
|
* Use for CSS/Framer Motion components that can't use useAnimationLoop.
|
||||||
|
*/
|
||||||
|
export function useAnimationPause(): boolean {
|
||||||
|
const [paused, setPaused] = useState(() =>
|
||||||
|
typeof document !== 'undefined' ? document.hidden : false,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let docHidden = document.hidden;
|
||||||
|
let tgInactive = false;
|
||||||
|
|
||||||
|
const update = () => setPaused(docHidden || tgInactive);
|
||||||
|
|
||||||
|
const onVisibility = () => {
|
||||||
|
docHidden = document.hidden;
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
const onActivated = () => {
|
||||||
|
tgInactive = false;
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
const onDeactivated = () => {
|
||||||
|
tgInactive = true;
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', onVisibility);
|
||||||
|
if (tg) {
|
||||||
|
tg.onEvent?.('activated', onActivated);
|
||||||
|
tg.onEvent?.('deactivated', onDeactivated);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility);
|
||||||
|
if (tg) {
|
||||||
|
tg.offEvent?.('activated', onActivated);
|
||||||
|
tg.offEvent?.('deactivated', onDeactivated);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a reduced DPR for mobile canvas rendering (saves 4x GPU work on retina) */
|
||||||
|
export function getMobileDpr(): number {
|
||||||
|
if (isMobile) return Math.min(devicePixelRatio, 1.5);
|
||||||
|
return devicePixelRatio || 1;
|
||||||
|
}
|
||||||
84
src/hooks/useChartColors.ts
Normal file
84
src/hooks/useChartColors.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { rgbToHex } from '../utils/colorConversion';
|
||||||
|
|
||||||
|
/** CSS variable names for chart theme colors */
|
||||||
|
const CSS_VARS = {
|
||||||
|
earnings: '--color-success-400',
|
||||||
|
referrals: '--color-accent-400',
|
||||||
|
grid: '--color-dark-700',
|
||||||
|
tooltipBg: '--color-dark-800',
|
||||||
|
tooltipBorder: '--color-dark-600',
|
||||||
|
tick: '--color-dark-400',
|
||||||
|
label: '--color-dark-300',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Fallback hex colors (default dark theme) */
|
||||||
|
const FALLBACK: Record<keyof typeof CSS_VARS, string> = {
|
||||||
|
earnings: '#34d399',
|
||||||
|
referrals: '#818cf8',
|
||||||
|
grid: '#374151',
|
||||||
|
tooltipBg: '#1f2937',
|
||||||
|
tooltipBorder: '#4b5563',
|
||||||
|
tick: '#9ca3af',
|
||||||
|
label: '#d1d5db',
|
||||||
|
};
|
||||||
|
|
||||||
|
function cssVarToHex(varName: string, fallback: string): string {
|
||||||
|
if (typeof document === 'undefined') return fallback;
|
||||||
|
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||||
|
if (!raw) return fallback;
|
||||||
|
|
||||||
|
const parts = raw.split(',').map((s) => parseInt(s.trim(), 10));
|
||||||
|
if (parts.length !== 3 || parts.some(isNaN)) return fallback;
|
||||||
|
|
||||||
|
return rgbToHex(parts[0], parts[1], parts[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartColors {
|
||||||
|
earnings: string;
|
||||||
|
referrals: string;
|
||||||
|
grid: string;
|
||||||
|
tooltipBg: string;
|
||||||
|
tooltipBorder: string;
|
||||||
|
tick: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads theme-aware colors from CSS custom properties for use in Recharts.
|
||||||
|
* Returns hex values derived from the current theme's CSS variables,
|
||||||
|
* adapting to light/dark mode and admin-customized accent colors.
|
||||||
|
*
|
||||||
|
* Colors are memoized and only recomputed when the document element's
|
||||||
|
* class attribute changes (i.e. theme switch).
|
||||||
|
*/
|
||||||
|
export function useChartColors(): ChartColors {
|
||||||
|
const [themeKey, setThemeKey] = useState(() =>
|
||||||
|
typeof document !== 'undefined' ? document.documentElement.className : '',
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
setThemeKey(document.documentElement.className);
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const colors = useMemo<ChartColors>(
|
||||||
|
() => ({
|
||||||
|
earnings: cssVarToHex(CSS_VARS.earnings, FALLBACK.earnings),
|
||||||
|
referrals: cssVarToHex(CSS_VARS.referrals, FALLBACK.referrals),
|
||||||
|
grid: cssVarToHex(CSS_VARS.grid, FALLBACK.grid),
|
||||||
|
tooltipBg: cssVarToHex(CSS_VARS.tooltipBg, FALLBACK.tooltipBg),
|
||||||
|
tooltipBorder: cssVarToHex(CSS_VARS.tooltipBorder, FALLBACK.tooltipBorder),
|
||||||
|
tick: cssVarToHex(CSS_VARS.tick, FALLBACK.tick),
|
||||||
|
label: cssVarToHex(CSS_VARS.label, FALLBACK.label),
|
||||||
|
}),
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[themeKey],
|
||||||
|
);
|
||||||
|
|
||||||
|
return colors;
|
||||||
|
}
|
||||||
@@ -318,6 +318,11 @@
|
|||||||
"price": "Price",
|
"price": "Price",
|
||||||
"noSubscription": "You don't have an active subscription",
|
"noSubscription": "You don't have an active subscription",
|
||||||
"getSubscription": "Get Subscription",
|
"getSubscription": "Get Subscription",
|
||||||
|
"cta": {
|
||||||
|
"expiredHint": "Choose a plan and pay",
|
||||||
|
"trialHint": "More traffic and devices",
|
||||||
|
"activeHint": "Renewal and plan change"
|
||||||
|
},
|
||||||
"connectionInfo": "Connection Info",
|
"connectionInfo": "Connection Info",
|
||||||
"copyLink": "Copy Link",
|
"copyLink": "Copy Link",
|
||||||
"copied": "Copied!",
|
"copied": "Copied!",
|
||||||
@@ -422,7 +427,10 @@
|
|||||||
"notConfigured": "Connection methods are not configured yet",
|
"notConfigured": "Connection methods are not configured yet",
|
||||||
"notConfiguredUser": "Connection setup is in progress. Please check back later.",
|
"notConfiguredUser": "Connection setup is in progress. Please check back later.",
|
||||||
"notConfiguredAdmin": "No connection apps have been configured. Set them up in the Apps settings.",
|
"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",
|
"myDevices": "My Devices",
|
||||||
"noDevices": "No connected devices",
|
"noDevices": "No connected devices",
|
||||||
@@ -694,6 +702,8 @@
|
|||||||
},
|
},
|
||||||
"yourReferrals": "Your Referrals",
|
"yourReferrals": "Your Referrals",
|
||||||
"noReferrals": "No referrals yet. Share your link to invite friends!",
|
"noReferrals": "No referrals yet. Share your link to invite friends!",
|
||||||
|
"anonymousUser": "User #{{id}}",
|
||||||
|
"anonymousReferral": "Referral",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"joined": "Joined",
|
"joined": "Joined",
|
||||||
"hasPaid": "Has Paid",
|
"hasPaid": "Has Paid",
|
||||||
@@ -742,6 +752,28 @@
|
|||||||
"tariff": "Bonus tariff",
|
"tariff": "Bonus tariff",
|
||||||
"tariffDesc": "Free tariff plan"
|
"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": {
|
"fields": {
|
||||||
"companyName": "Company Name",
|
"companyName": "Company Name",
|
||||||
"companyNamePlaceholder": "Your company or brand name",
|
"companyNamePlaceholder": "Your company or brand name",
|
||||||
@@ -752,7 +784,9 @@
|
|||||||
"description": "Description",
|
"description": "Description",
|
||||||
"descriptionPlaceholder": "Tell us about your audience and how you plan to promote...",
|
"descriptionPlaceholder": "Tell us about your audience and how you plan to promote...",
|
||||||
"expectedReferrals": "Expected Monthly Referrals",
|
"expectedReferrals": "Expected Monthly Referrals",
|
||||||
"expectedReferralsPlaceholder": "Estimated number per month"
|
"expectedReferralsPlaceholder": "Estimated number per month",
|
||||||
|
"desiredCommission": "Desired Commission (%)",
|
||||||
|
"desiredCommissionPlaceholder": "From 1 to 100%"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"withdrawal": {
|
"withdrawal": {
|
||||||
@@ -773,9 +807,9 @@
|
|||||||
"submitRequest": "Submit Request",
|
"submitRequest": "Submit Request",
|
||||||
"requestError": "Failed to create withdrawal request. Please try again.",
|
"requestError": "Failed to create withdrawal request. Please try again.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"amount": "Amount (kopeks)",
|
"amount": "Amount",
|
||||||
"amountPlaceholder": "Enter amount in kopeks",
|
"amountPlaceholder": "Enter amount in {{currency}}",
|
||||||
"amountHint": "Enter the amount in kopeks (100 kopeks = 1 {{currency}})",
|
"amountHint": "Minimum: {{min}} {{currency}}",
|
||||||
"paymentDetails": "Payment Details",
|
"paymentDetails": "Payment Details",
|
||||||
"paymentDetailsPlaceholder": "Card number, wallet address, or other payment info..."
|
"paymentDetailsPlaceholder": "Card number, wallet address, or other payment info..."
|
||||||
},
|
},
|
||||||
@@ -858,7 +892,8 @@
|
|||||||
"cannotSpin": "Cannot spin right now",
|
"cannotSpin": "Cannot spin right now",
|
||||||
"insufficientBalance": "Insufficient balance",
|
"insufficientBalance": "Insufficient balance",
|
||||||
"topUpRequired": "Top up your balance to pay for spin",
|
"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",
|
"payWithStars": "Pay with Stars",
|
||||||
"payWithDays": "Pay with days",
|
"payWithDays": "Pay with days",
|
||||||
@@ -1007,7 +1042,8 @@
|
|||||||
"roles": "Roles",
|
"roles": "Roles",
|
||||||
"roleAssign": "Role Assignment",
|
"roleAssign": "Role Assignment",
|
||||||
"policies": "Access Policies",
|
"policies": "Access Policies",
|
||||||
"auditLog": "Audit Log"
|
"auditLog": "Audit Log",
|
||||||
|
"salesStats": "Sales Statistics"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Admin Panel",
|
"title": "Admin Panel",
|
||||||
@@ -1039,7 +1075,87 @@
|
|||||||
"rolesDesc": "Manage admin roles and permissions",
|
"rolesDesc": "Manage admin roles and permissions",
|
||||||
"roleAssignDesc": "Assign and revoke user roles",
|
"roleAssignDesc": "Assign and revoke user roles",
|
||||||
"policiesDesc": "Configure ABAC access policies",
|
"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": {
|
"trafficUsage": {
|
||||||
"title": "Traffic Usage",
|
"title": "Traffic Usage",
|
||||||
@@ -1980,7 +2096,15 @@
|
|||||||
"users": "Users",
|
"users": "Users",
|
||||||
"noUsers": "No registered users",
|
"noUsers": "No registered users",
|
||||||
"paid": "Paid",
|
"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": {
|
"users": {
|
||||||
"title": "Campaign Users",
|
"title": "Campaign Users",
|
||||||
@@ -2042,6 +2166,7 @@
|
|||||||
"balance": "Balance",
|
"balance": "Balance",
|
||||||
"totalReferrals": "Total referrals",
|
"totalReferrals": "Total referrals",
|
||||||
"totalEarnings": "Total earnings",
|
"totalEarnings": "Total earnings",
|
||||||
|
"requestedAmount": "Requested Amount",
|
||||||
"createdAt": "Created",
|
"createdAt": "Created",
|
||||||
"paymentDetails": "Payment Details",
|
"paymentDetails": "Payment Details",
|
||||||
"noPaymentDetails": "No payment details provided",
|
"noPaymentDetails": "No payment details provided",
|
||||||
@@ -2097,7 +2222,8 @@
|
|||||||
"website": "Website",
|
"website": "Website",
|
||||||
"channel": "Channel",
|
"channel": "Channel",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"expectedReferrals": "Expected referrals/mo"
|
"expectedReferrals": "Expected referrals/mo",
|
||||||
|
"desiredCommission": "Desired commission"
|
||||||
},
|
},
|
||||||
"approveDialog": {
|
"approveDialog": {
|
||||||
"title": "Approve Application",
|
"title": "Approve Application",
|
||||||
|
|||||||
@@ -327,7 +327,10 @@
|
|||||||
"notConfigured": "روشهای اتصال هنوز پیکربندی نشدهاند",
|
"notConfigured": "روشهای اتصال هنوز پیکربندی نشدهاند",
|
||||||
"notConfiguredUser": "تنظیمات اتصال در حال انجام است. لطفاً بعداً مراجعه کنید.",
|
"notConfiguredUser": "تنظیمات اتصال در حال انجام است. لطفاً بعداً مراجعه کنید.",
|
||||||
"notConfiguredAdmin": "هیچ برنامه اتصالی پیکربندی نشده است. آنها را در تنظیمات برنامهها تنظیم کنید.",
|
"notConfiguredAdmin": "هیچ برنامه اتصالی پیکربندی نشده است. آنها را در تنظیمات برنامهها تنظیم کنید.",
|
||||||
"goToApps": "رفتن به تنظیمات برنامهها"
|
"goToApps": "رفتن به تنظیمات برنامهها",
|
||||||
|
"qrTitle": "کد QR اشتراک",
|
||||||
|
"qrScanHint": "برای اتصال با دوربین اسکن کنید",
|
||||||
|
"qrButton": "کد QR"
|
||||||
},
|
},
|
||||||
"additionalOptions": {
|
"additionalOptions": {
|
||||||
"title": "گزینههای اضافی",
|
"title": "گزینههای اضافی",
|
||||||
@@ -533,6 +536,8 @@
|
|||||||
},
|
},
|
||||||
"yourReferrals": "معرفیهای شما",
|
"yourReferrals": "معرفیهای شما",
|
||||||
"noReferrals": "هنوز معرفی ندارید. لینک را به اشتراک بگذارید!",
|
"noReferrals": "هنوز معرفی ندارید. لینک را به اشتراک بگذارید!",
|
||||||
|
"anonymousUser": "کاربر #{{id}}",
|
||||||
|
"anonymousReferral": "معرفیشده",
|
||||||
"user": "کاربر",
|
"user": "کاربر",
|
||||||
"joined": "تاریخ عضویت",
|
"joined": "تاریخ عضویت",
|
||||||
"hasPaid": "پرداخت کرده",
|
"hasPaid": "پرداخت کرده",
|
||||||
@@ -581,6 +586,28 @@
|
|||||||
"tariff": "تعرفه جایزه",
|
"tariff": "تعرفه جایزه",
|
||||||
"tariffDesc": "طرح تعرفه رایگان"
|
"tariffDesc": "طرح تعرفه رایگان"
|
||||||
},
|
},
|
||||||
|
"stats": {
|
||||||
|
"registrations": "ثبتنام",
|
||||||
|
"referrals": "معرفیشدهها",
|
||||||
|
"earnings": "درآمد",
|
||||||
|
"conversionRate": "نرخ تبدیل",
|
||||||
|
"today": "امروز",
|
||||||
|
"week": "هفته",
|
||||||
|
"month": "ماه",
|
||||||
|
"showDetails": "جزئیات",
|
||||||
|
"hideDetails": "بستن",
|
||||||
|
"vsLastWeek": "مقایسه با هفته قبل",
|
||||||
|
"topReferrals": "برترین معرفیشدهها",
|
||||||
|
"noData": "دادهای نیست",
|
||||||
|
"dailyChart": "روند ۳۰ روزه",
|
||||||
|
"periodComparison": "مقایسه دورهها",
|
||||||
|
"noReferrals": "هنوز معرفیشدهای نیست",
|
||||||
|
"paid": "پرداخت شده",
|
||||||
|
"pending": "در انتظار",
|
||||||
|
"active": "فعال",
|
||||||
|
"referralsCount": "تعداد معرفی",
|
||||||
|
"earningsAmount": "کسب شده"
|
||||||
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"companyName": "نام شرکت",
|
"companyName": "نام شرکت",
|
||||||
"companyNamePlaceholder": "نام شرکت یا برند شما",
|
"companyNamePlaceholder": "نام شرکت یا برند شما",
|
||||||
@@ -591,7 +618,9 @@
|
|||||||
"description": "توضیحات",
|
"description": "توضیحات",
|
||||||
"descriptionPlaceholder": "درباره مخاطبان خود و نحوه تبلیغ بگویید...",
|
"descriptionPlaceholder": "درباره مخاطبان خود و نحوه تبلیغ بگویید...",
|
||||||
"expectedReferrals": "تعداد معرفی ماهانه مورد انتظار",
|
"expectedReferrals": "تعداد معرفی ماهانه مورد انتظار",
|
||||||
"expectedReferralsPlaceholder": "تعداد تخمینی در ماه"
|
"expectedReferralsPlaceholder": "تعداد تخمینی در ماه",
|
||||||
|
"desiredCommission": "درصد کمیسیون مورد نظر",
|
||||||
|
"desiredCommissionPlaceholder": "از ۱ تا ۱۰۰٪"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"withdrawal": {
|
"withdrawal": {
|
||||||
@@ -612,9 +641,9 @@
|
|||||||
"submitRequest": "ارسال درخواست",
|
"submitRequest": "ارسال درخواست",
|
||||||
"requestError": "ایجاد درخواست برداشت ناموفق بود. لطفاً دوباره تلاش کنید.",
|
"requestError": "ایجاد درخواست برداشت ناموفق بود. لطفاً دوباره تلاش کنید.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"amount": "مبلغ (کوپک)",
|
"amount": "مبلغ",
|
||||||
"amountPlaceholder": "مبلغ را به کوپک وارد کنید",
|
"amountPlaceholder": "مبلغ را به {{currency}} وارد کنید",
|
||||||
"amountHint": "مبلغ را به کوپک وارد کنید (۱۰۰ کوپک = ۱ {{currency}})",
|
"amountHint": "حداقل: {{min}} {{currency}}",
|
||||||
"paymentDetails": "اطلاعات پرداخت",
|
"paymentDetails": "اطلاعات پرداخت",
|
||||||
"paymentDetailsPlaceholder": "شماره کارت، آدرس کیف پول یا سایر اطلاعات پرداخت..."
|
"paymentDetailsPlaceholder": "شماره کارت، آدرس کیف پول یا سایر اطلاعات پرداخت..."
|
||||||
},
|
},
|
||||||
@@ -694,7 +723,8 @@
|
|||||||
"cannotSpin": "فعلاً امکان چرخش نیست",
|
"cannotSpin": "فعلاً امکان چرخش نیست",
|
||||||
"insufficientBalance": "موجودی کافی نیست",
|
"insufficientBalance": "موجودی کافی نیست",
|
||||||
"topUpRequired": "برای پرداخت شارژ کنید",
|
"topUpRequired": "برای پرداخت شارژ کنید",
|
||||||
"starsNotAvailable": "پرداخت Stars در دسترس نیست. برنامه را از طریق تلگرام باز کنید."
|
"starsNotAvailable": "پرداخت Stars در دسترس نیست. برنامه را از طریق تلگرام باز کنید.",
|
||||||
|
"noSubscription": "برای چرخاندن گردونه نیاز به اشتراک فعال دارید. ابتدا اشتراک خود را فعال کنید."
|
||||||
},
|
},
|
||||||
"payWithStars": "پرداخت {stars} Stars",
|
"payWithStars": "پرداخت {stars} Stars",
|
||||||
"processingPayment": "در حال پردازش...",
|
"processingPayment": "در حال پردازش...",
|
||||||
@@ -835,7 +865,8 @@
|
|||||||
"roles": "نقشها",
|
"roles": "نقشها",
|
||||||
"roleAssign": "تخصیص نقش",
|
"roleAssign": "تخصیص نقش",
|
||||||
"policies": "سیاستهای دسترسی",
|
"policies": "سیاستهای دسترسی",
|
||||||
"auditLog": "گزارش بازرسی"
|
"auditLog": "گزارش بازرسی",
|
||||||
|
"salesStats": "آمار فروش"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "پنل مدیریت",
|
"title": "پنل مدیریت",
|
||||||
@@ -867,7 +898,87 @@
|
|||||||
"rolesDesc": "مدیریت نقشها و مجوزهای مدیران",
|
"rolesDesc": "مدیریت نقشها و مجوزهای مدیران",
|
||||||
"roleAssignDesc": "تخصیص و لغو نقشهای کاربران",
|
"roleAssignDesc": "تخصیص و لغو نقشهای کاربران",
|
||||||
"policiesDesc": "تنظیم سیاستهای کنترل دسترسی ABAC",
|
"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": {
|
"trafficUsage": {
|
||||||
"title": "مصرف ترافیک",
|
"title": "مصرف ترافیک",
|
||||||
@@ -1664,7 +1775,15 @@
|
|||||||
"users": "کاربران",
|
"users": "کاربران",
|
||||||
"noUsers": "کاربر ثبتنام شدهای وجود ندارد",
|
"noUsers": "کاربر ثبتنام شدهای وجود ندارد",
|
||||||
"paid": "پرداخت کرده",
|
"paid": "پرداخت کرده",
|
||||||
"hasSub": "دارای اشتراک"
|
"hasSub": "دارای اشتراک",
|
||||||
|
"dailyChart": "روند ۳۰ روزه",
|
||||||
|
"chartRegistrations": "ثبتنامها",
|
||||||
|
"chartRevenue": "درآمد",
|
||||||
|
"totalDeposits": "کل واریزیها",
|
||||||
|
"totalSpending": "هزینه اشتراک",
|
||||||
|
"periodComparison": "مقایسه دورهها",
|
||||||
|
"vsLastWeek": "نسبت به هفته قبل",
|
||||||
|
"topRegistrations": "بالاترین ثبتنامها"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "کاربران کمپین",
|
"title": "کاربران کمپین",
|
||||||
@@ -1714,7 +1833,8 @@
|
|||||||
"approved": "تأیید شده",
|
"approved": "تأیید شده",
|
||||||
"rejected": "رد شده",
|
"rejected": "رد شده",
|
||||||
"completed": "تکمیل شده",
|
"completed": "تکمیل شده",
|
||||||
"cancelled": "لغو شده"
|
"cancelled": "لغو شده",
|
||||||
|
"unknown": "ناشناخته"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "جزئیات برداشت",
|
"title": "جزئیات برداشت",
|
||||||
@@ -1725,6 +1845,7 @@
|
|||||||
"balance": "موجودی",
|
"balance": "موجودی",
|
||||||
"totalReferrals": "مجموع معرفیها",
|
"totalReferrals": "مجموع معرفیها",
|
||||||
"totalEarnings": "مجموع درآمد",
|
"totalEarnings": "مجموع درآمد",
|
||||||
|
"requestedAmount": "مبلغ درخواستی",
|
||||||
"createdAt": "تاریخ ایجاد",
|
"createdAt": "تاریخ ایجاد",
|
||||||
"paymentDetails": "اطلاعات پرداخت",
|
"paymentDetails": "اطلاعات پرداخت",
|
||||||
"noPaymentDetails": "اطلاعات پرداختی ارائه نشده",
|
"noPaymentDetails": "اطلاعات پرداختی ارائه نشده",
|
||||||
@@ -1780,7 +1901,8 @@
|
|||||||
"website": "وبسایت",
|
"website": "وبسایت",
|
||||||
"channel": "کانال",
|
"channel": "کانال",
|
||||||
"description": "توضیحات",
|
"description": "توضیحات",
|
||||||
"expectedReferrals": "معرفی مورد انتظار/ماه"
|
"expectedReferrals": "معرفی مورد انتظار/ماه",
|
||||||
|
"desiredCommission": "کمیسیون مورد نظر"
|
||||||
},
|
},
|
||||||
"approveDialog": {
|
"approveDialog": {
|
||||||
"title": "تأیید درخواست",
|
"title": "تأیید درخواست",
|
||||||
|
|||||||
@@ -336,6 +336,11 @@
|
|||||||
"price": "Цена",
|
"price": "Цена",
|
||||||
"noSubscription": "У вас нет активной подписки",
|
"noSubscription": "У вас нет активной подписки",
|
||||||
"getSubscription": "Оформить подписку",
|
"getSubscription": "Оформить подписку",
|
||||||
|
"cta": {
|
||||||
|
"expiredHint": "Выберите тариф и оплатите",
|
||||||
|
"trialHint": "Больше трафика и устройств",
|
||||||
|
"activeHint": "Продление и смена тарифа"
|
||||||
|
},
|
||||||
"connectionInfo": "Данные для подключения",
|
"connectionInfo": "Данные для подключения",
|
||||||
"copyLink": "Копировать ссылку",
|
"copyLink": "Копировать ссылку",
|
||||||
"copied": "Скопировано!",
|
"copied": "Скопировано!",
|
||||||
@@ -445,7 +450,10 @@
|
|||||||
"notConfigured": "Способы подключения ещё не настроены",
|
"notConfigured": "Способы подключения ещё не настроены",
|
||||||
"notConfiguredUser": "Настройка подключения в процессе. Пожалуйста, загляните позже.",
|
"notConfiguredUser": "Настройка подключения в процессе. Пожалуйста, загляните позже.",
|
||||||
"notConfiguredAdmin": "Приложения для подключения не настроены. Настройте их в разделе Приложения.",
|
"notConfiguredAdmin": "Приложения для подключения не настроены. Настройте их в разделе Приложения.",
|
||||||
"goToApps": "Перейти к настройке приложений"
|
"goToApps": "Перейти к настройке приложений",
|
||||||
|
"qrTitle": "QR-код подписки",
|
||||||
|
"qrScanHint": "Отсканируйте для подключения",
|
||||||
|
"qrButton": "QR-код"
|
||||||
},
|
},
|
||||||
"myDevices": "Мои устройства",
|
"myDevices": "Мои устройства",
|
||||||
"noDevices": "Нет подключенных устройств",
|
"noDevices": "Нет подключенных устройств",
|
||||||
@@ -722,6 +730,8 @@
|
|||||||
},
|
},
|
||||||
"yourReferrals": "Ваши рефералы",
|
"yourReferrals": "Ваши рефералы",
|
||||||
"noReferrals": "Пока нет рефералов. Поделитесь ссылкой, чтобы пригласить друзей!",
|
"noReferrals": "Пока нет рефералов. Поделитесь ссылкой, чтобы пригласить друзей!",
|
||||||
|
"anonymousUser": "Пользователь #{{id}}",
|
||||||
|
"anonymousReferral": "Реферал",
|
||||||
"user": "Пользователь",
|
"user": "Пользователь",
|
||||||
"joined": "Присоединился",
|
"joined": "Присоединился",
|
||||||
"hasPaid": "Оплатил",
|
"hasPaid": "Оплатил",
|
||||||
@@ -770,6 +780,28 @@
|
|||||||
"tariff": "Бонусный тариф",
|
"tariff": "Бонусный тариф",
|
||||||
"tariffDesc": "Бесплатный тарифный план"
|
"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": {
|
"fields": {
|
||||||
"companyName": "Название компании",
|
"companyName": "Название компании",
|
||||||
"companyNamePlaceholder": "Название вашей компании или бренда",
|
"companyNamePlaceholder": "Название вашей компании или бренда",
|
||||||
@@ -780,7 +812,9 @@
|
|||||||
"description": "Описание",
|
"description": "Описание",
|
||||||
"descriptionPlaceholder": "Расскажите о вашей аудитории и планах продвижения...",
|
"descriptionPlaceholder": "Расскажите о вашей аудитории и планах продвижения...",
|
||||||
"expectedReferrals": "Ожидаемое количество рефералов в месяц",
|
"expectedReferrals": "Ожидаемое количество рефералов в месяц",
|
||||||
"expectedReferralsPlaceholder": "Примерное количество в месяц"
|
"expectedReferralsPlaceholder": "Примерное количество в месяц",
|
||||||
|
"desiredCommission": "Желаемый процент комиссии",
|
||||||
|
"desiredCommissionPlaceholder": "От 1 до 100%"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"withdrawal": {
|
"withdrawal": {
|
||||||
@@ -801,9 +835,9 @@
|
|||||||
"submitRequest": "Отправить запрос",
|
"submitRequest": "Отправить запрос",
|
||||||
"requestError": "Не удалось создать запрос на вывод. Попробуйте снова.",
|
"requestError": "Не удалось создать запрос на вывод. Попробуйте снова.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"amount": "Сумма (копейки)",
|
"amount": "Сумма",
|
||||||
"amountPlaceholder": "Введите сумму в копейках",
|
"amountPlaceholder": "Введите сумму в {{currency}}",
|
||||||
"amountHint": "Введите сумму в копейках (100 копеек = 1 {{currency}})",
|
"amountHint": "Минимум: {{min}} {{currency}}",
|
||||||
"paymentDetails": "Реквизиты для оплаты",
|
"paymentDetails": "Реквизиты для оплаты",
|
||||||
"paymentDetailsPlaceholder": "Номер карты, адрес кошелька или другие реквизиты..."
|
"paymentDetailsPlaceholder": "Номер карты, адрес кошелька или другие реквизиты..."
|
||||||
},
|
},
|
||||||
@@ -887,7 +921,8 @@
|
|||||||
"cannotSpin": "Сейчас нельзя крутить",
|
"cannotSpin": "Сейчас нельзя крутить",
|
||||||
"insufficientBalance": "Недостаточно средств на балансе",
|
"insufficientBalance": "Недостаточно средств на балансе",
|
||||||
"topUpRequired": "Пополните баланс для оплаты спина",
|
"topUpRequired": "Пополните баланс для оплаты спина",
|
||||||
"starsNotAvailable": "Оплата Stars недоступна. Откройте приложение через Telegram."
|
"starsNotAvailable": "Оплата Stars недоступна. Откройте приложение через Telegram.",
|
||||||
|
"noSubscription": "Для вращения колеса необходима активная подписка. Активируйте подписку в разделе «Подписка»."
|
||||||
},
|
},
|
||||||
"payWithStars": "Оплатить Stars",
|
"payWithStars": "Оплатить Stars",
|
||||||
"payWithDays": "Оплатить днями",
|
"payWithDays": "Оплатить днями",
|
||||||
@@ -1028,7 +1063,8 @@
|
|||||||
"roles": "Роли",
|
"roles": "Роли",
|
||||||
"roleAssign": "Назначение ролей",
|
"roleAssign": "Назначение ролей",
|
||||||
"policies": "Политики доступа",
|
"policies": "Политики доступа",
|
||||||
"auditLog": "Журнал аудита"
|
"auditLog": "Журнал аудита",
|
||||||
|
"salesStats": "Статистика продаж"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "Панель администратора",
|
"title": "Панель администратора",
|
||||||
@@ -1060,7 +1096,87 @@
|
|||||||
"rolesDesc": "Управление ролями и правами администраторов",
|
"rolesDesc": "Управление ролями и правами администраторов",
|
||||||
"roleAssignDesc": "Назначение и отзыв ролей пользователей",
|
"roleAssignDesc": "Назначение и отзыв ролей пользователей",
|
||||||
"policiesDesc": "Настройка политик контроля доступа ABAC",
|
"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": {
|
"trafficUsage": {
|
||||||
"title": "Расход трафика",
|
"title": "Расход трафика",
|
||||||
@@ -2501,7 +2617,15 @@
|
|||||||
"users": "Пользователи",
|
"users": "Пользователи",
|
||||||
"noUsers": "Нет зарегистрированных пользователей",
|
"noUsers": "Нет зарегистрированных пользователей",
|
||||||
"paid": "Платил",
|
"paid": "Платил",
|
||||||
"hasSub": "Подписка"
|
"hasSub": "Подписка",
|
||||||
|
"dailyChart": "Динамика за 30 дней",
|
||||||
|
"chartRegistrations": "Регистрации",
|
||||||
|
"chartRevenue": "Доход",
|
||||||
|
"totalDeposits": "Всего пополнений",
|
||||||
|
"totalSpending": "Расход на подписки",
|
||||||
|
"periodComparison": "Сравнение периодов",
|
||||||
|
"vsLastWeek": "vs прошлая неделя",
|
||||||
|
"topRegistrations": "Топ регистрации"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "Пользователи кампании",
|
"title": "Пользователи кампании",
|
||||||
@@ -2563,6 +2687,7 @@
|
|||||||
"balance": "Баланс",
|
"balance": "Баланс",
|
||||||
"totalReferrals": "Всего рефералов",
|
"totalReferrals": "Всего рефералов",
|
||||||
"totalEarnings": "Общий заработок",
|
"totalEarnings": "Общий заработок",
|
||||||
|
"requestedAmount": "Запрошенная сумма",
|
||||||
"createdAt": "Создано",
|
"createdAt": "Создано",
|
||||||
"paymentDetails": "Реквизиты для оплаты",
|
"paymentDetails": "Реквизиты для оплаты",
|
||||||
"noPaymentDetails": "Реквизиты не указаны",
|
"noPaymentDetails": "Реквизиты не указаны",
|
||||||
@@ -2618,7 +2743,8 @@
|
|||||||
"website": "Сайт",
|
"website": "Сайт",
|
||||||
"channel": "Канал",
|
"channel": "Канал",
|
||||||
"description": "Описание",
|
"description": "Описание",
|
||||||
"expectedReferrals": "Ожидаемых рефералов/мес"
|
"expectedReferrals": "Ожидаемых рефералов/мес",
|
||||||
|
"desiredCommission": "Желаемая комиссия"
|
||||||
},
|
},
|
||||||
"approveDialog": {
|
"approveDialog": {
|
||||||
"title": "Одобрить заявку",
|
"title": "Одобрить заявку",
|
||||||
|
|||||||
@@ -327,7 +327,10 @@
|
|||||||
"notConfigured": "连接方式尚未配置",
|
"notConfigured": "连接方式尚未配置",
|
||||||
"notConfiguredUser": "连接设置正在进行中,请稍后再来查看。",
|
"notConfiguredUser": "连接设置正在进行中,请稍后再来查看。",
|
||||||
"notConfiguredAdmin": "尚未配置连接应用。请在应用设置中进行配置。",
|
"notConfiguredAdmin": "尚未配置连接应用。请在应用设置中进行配置。",
|
||||||
"goToApps": "前往应用设置"
|
"goToApps": "前往应用设置",
|
||||||
|
"qrTitle": "订阅二维码",
|
||||||
|
"qrScanHint": "使用相机扫描以连接",
|
||||||
|
"qrButton": "二维码"
|
||||||
},
|
},
|
||||||
"additionalOptions": {
|
"additionalOptions": {
|
||||||
"title": "附加选项",
|
"title": "附加选项",
|
||||||
@@ -533,6 +536,8 @@
|
|||||||
},
|
},
|
||||||
"yourReferrals": "您的推荐",
|
"yourReferrals": "您的推荐",
|
||||||
"noReferrals": "暂无推荐。分享链接邀请好友!",
|
"noReferrals": "暂无推荐。分享链接邀请好友!",
|
||||||
|
"anonymousUser": "用户 #{{id}}",
|
||||||
|
"anonymousReferral": "推荐人",
|
||||||
"user": "用户",
|
"user": "用户",
|
||||||
"joined": "加入时间",
|
"joined": "加入时间",
|
||||||
"hasPaid": "已付款",
|
"hasPaid": "已付款",
|
||||||
@@ -581,6 +586,28 @@
|
|||||||
"tariff": "奖励套餐",
|
"tariff": "奖励套餐",
|
||||||
"tariffDesc": "免费套餐方案"
|
"tariffDesc": "免费套餐方案"
|
||||||
},
|
},
|
||||||
|
"stats": {
|
||||||
|
"registrations": "注册",
|
||||||
|
"referrals": "推荐",
|
||||||
|
"earnings": "收入",
|
||||||
|
"conversionRate": "转化率",
|
||||||
|
"today": "今天",
|
||||||
|
"week": "本周",
|
||||||
|
"month": "本月",
|
||||||
|
"showDetails": "详情",
|
||||||
|
"hideDetails": "收起",
|
||||||
|
"vsLastWeek": "对比上周",
|
||||||
|
"topReferrals": "热门推荐",
|
||||||
|
"noData": "暂无数据",
|
||||||
|
"dailyChart": "30天趋势",
|
||||||
|
"periodComparison": "周期对比",
|
||||||
|
"noReferrals": "暂无推荐",
|
||||||
|
"paid": "已付款",
|
||||||
|
"pending": "待处理",
|
||||||
|
"active": "活跃",
|
||||||
|
"referralsCount": "推荐数",
|
||||||
|
"earningsAmount": "收入"
|
||||||
|
},
|
||||||
"fields": {
|
"fields": {
|
||||||
"companyName": "公司名称",
|
"companyName": "公司名称",
|
||||||
"companyNamePlaceholder": "您的公司或品牌名称",
|
"companyNamePlaceholder": "您的公司或品牌名称",
|
||||||
@@ -591,7 +618,9 @@
|
|||||||
"description": "描述",
|
"description": "描述",
|
||||||
"descriptionPlaceholder": "请介绍您的受众以及推广计划...",
|
"descriptionPlaceholder": "请介绍您的受众以及推广计划...",
|
||||||
"expectedReferrals": "预计每月推荐数",
|
"expectedReferrals": "预计每月推荐数",
|
||||||
"expectedReferralsPlaceholder": "每月预估数量"
|
"expectedReferralsPlaceholder": "每月预估数量",
|
||||||
|
"desiredCommission": "期望佣金比例 (%)",
|
||||||
|
"desiredCommissionPlaceholder": "1 到 100%"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"withdrawal": {
|
"withdrawal": {
|
||||||
@@ -612,9 +641,9 @@
|
|||||||
"submitRequest": "提交请求",
|
"submitRequest": "提交请求",
|
||||||
"requestError": "创建提现请求失败,请重试。",
|
"requestError": "创建提现请求失败,请重试。",
|
||||||
"fields": {
|
"fields": {
|
||||||
"amount": "金额(戈比)",
|
"amount": "金额",
|
||||||
"amountPlaceholder": "输入金额(戈比)",
|
"amountPlaceholder": "输入金额({{currency}})",
|
||||||
"amountHint": "请输入戈比金额(100戈比 = 1 {{currency}})",
|
"amountHint": "最低:{{min}} {{currency}}",
|
||||||
"paymentDetails": "付款信息",
|
"paymentDetails": "付款信息",
|
||||||
"paymentDetailsPlaceholder": "银行卡号、钱包地址或其他付款信息..."
|
"paymentDetailsPlaceholder": "银行卡号、钱包地址或其他付款信息..."
|
||||||
},
|
},
|
||||||
@@ -698,7 +727,8 @@
|
|||||||
"cannotSpin": "当前无法抽奖",
|
"cannotSpin": "当前无法抽奖",
|
||||||
"insufficientBalance": "余额不足",
|
"insufficientBalance": "余额不足",
|
||||||
"topUpRequired": "请充值以支付抽奖费用",
|
"topUpRequired": "请充值以支付抽奖费用",
|
||||||
"starsNotAvailable": "Stars支付不可用。请通过Telegram打开应用。"
|
"starsNotAvailable": "Stars支付不可用。请通过Telegram打开应用。",
|
||||||
|
"noSubscription": "需要有效订阅才能转动轮盘。请先激活订阅。"
|
||||||
},
|
},
|
||||||
"payWithStars": "支付 {stars} Stars",
|
"payWithStars": "支付 {stars} Stars",
|
||||||
"processingPayment": "处理中...",
|
"processingPayment": "处理中...",
|
||||||
@@ -835,7 +865,8 @@
|
|||||||
"roles": "角色",
|
"roles": "角色",
|
||||||
"roleAssign": "角色分配",
|
"roleAssign": "角色分配",
|
||||||
"policies": "访问策略",
|
"policies": "访问策略",
|
||||||
"auditLog": "审计日志"
|
"auditLog": "审计日志",
|
||||||
|
"salesStats": "销售统计"
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
"title": "管理面板",
|
"title": "管理面板",
|
||||||
@@ -867,7 +898,87 @@
|
|||||||
"rolesDesc": "管理管理员角色和权限",
|
"rolesDesc": "管理管理员角色和权限",
|
||||||
"roleAssignDesc": "分配和撤销用户角色",
|
"roleAssignDesc": "分配和撤销用户角色",
|
||||||
"policiesDesc": "配置ABAC访问控制策略",
|
"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": {
|
"trafficUsage": {
|
||||||
"title": "流量使用",
|
"title": "流量使用",
|
||||||
@@ -1663,7 +1774,15 @@
|
|||||||
"users": "用户",
|
"users": "用户",
|
||||||
"noUsers": "没有注册用户",
|
"noUsers": "没有注册用户",
|
||||||
"paid": "已付费",
|
"paid": "已付费",
|
||||||
"hasSub": "有订阅"
|
"hasSub": "有订阅",
|
||||||
|
"dailyChart": "30天趋势",
|
||||||
|
"chartRegistrations": "注册",
|
||||||
|
"chartRevenue": "收入",
|
||||||
|
"totalDeposits": "总充值",
|
||||||
|
"totalSpending": "订阅支出",
|
||||||
|
"periodComparison": "期间比较",
|
||||||
|
"vsLastWeek": "与上周对比",
|
||||||
|
"topRegistrations": "热门注册"
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"title": "活动用户",
|
"title": "活动用户",
|
||||||
@@ -1713,7 +1832,8 @@
|
|||||||
"approved": "已批准",
|
"approved": "已批准",
|
||||||
"rejected": "已拒绝",
|
"rejected": "已拒绝",
|
||||||
"completed": "已完成",
|
"completed": "已完成",
|
||||||
"cancelled": "已取消"
|
"cancelled": "已取消",
|
||||||
|
"unknown": "未知"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"title": "提现详情",
|
"title": "提现详情",
|
||||||
@@ -1724,6 +1844,7 @@
|
|||||||
"balance": "余额",
|
"balance": "余额",
|
||||||
"totalReferrals": "总推荐数",
|
"totalReferrals": "总推荐数",
|
||||||
"totalEarnings": "总收益",
|
"totalEarnings": "总收益",
|
||||||
|
"requestedAmount": "请求金额",
|
||||||
"createdAt": "创建时间",
|
"createdAt": "创建时间",
|
||||||
"paymentDetails": "付款信息",
|
"paymentDetails": "付款信息",
|
||||||
"noPaymentDetails": "未提供付款信息",
|
"noPaymentDetails": "未提供付款信息",
|
||||||
@@ -1779,7 +1900,8 @@
|
|||||||
"website": "网站",
|
"website": "网站",
|
||||||
"channel": "频道",
|
"channel": "频道",
|
||||||
"description": "描述",
|
"description": "描述",
|
||||||
"expectedReferrals": "预计每月推荐数"
|
"expectedReferrals": "预计每月推荐数",
|
||||||
|
"desiredCommission": "期望佣金"
|
||||||
},
|
},
|
||||||
"approveDialog": {
|
"approveDialog": {
|
||||||
"title": "批准申请",
|
"title": "批准申请",
|
||||||
|
|||||||
@@ -136,6 +136,14 @@ export default function AdminApplicationReview() {
|
|||||||
<span className="text-dark-200">{app.expected_monthly_referrals}</span>
|
<span className="text-dark-200">{app.expected_monthly_referrals}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router';
|
import { useParams, useNavigate, Link } from 'react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import i18n from '../i18n';
|
|
||||||
import { campaignsApi, CampaignBonusType } from '../api/campaigns';
|
import { campaignsApi, CampaignBonusType } from '../api/campaigns';
|
||||||
|
import type { AdminCampaignChartData } from '../api/campaigns';
|
||||||
import { AdminBackButton } from '../components/admin';
|
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
|
// Icons
|
||||||
const CopyIcon = () => (
|
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() {
|
export default function AdminCampaignStats() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const numericId = id ? Number(id) : null;
|
||||||
|
const isValidId = numericId !== null && !isNaN(numericId);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const haptic = useHaptic();
|
||||||
|
const { formatWithCurrency } = useCurrency();
|
||||||
const [copiedBot, setCopiedBot] = useState(false);
|
const [copiedBot, setCopiedBot] = useState(false);
|
||||||
const [copiedWeb, setCopiedWeb] = useState(false);
|
const [copiedWeb, setCopiedWeb] = useState(false);
|
||||||
const [showUsers, setShowUsers] = useState(false);
|
const [showUsers, setShowUsers] = useState(false);
|
||||||
@@ -119,42 +107,66 @@ export default function AdminCampaignStats() {
|
|||||||
error,
|
error,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ['campaign-stats', id],
|
queryKey: ['campaign-stats', id],
|
||||||
queryFn: () => campaignsApi.getCampaignStats(Number(id)),
|
queryFn: () => campaignsApi.getCampaignStats(numericId!),
|
||||||
enabled: !!id,
|
enabled: isValidId,
|
||||||
|
staleTime: PARTNER_STATS.STATS_STALE_TIME,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch registrations when users section is open
|
// Fetch registrations when users section is open
|
||||||
const { data: registrationsData, isLoading: usersLoading } = useQuery({
|
const { data: registrationsData, isLoading: usersLoading } = useQuery({
|
||||||
queryKey: ['campaign-registrations', id],
|
queryKey: ['campaign-registrations', id],
|
||||||
queryFn: () => campaignsApi.getCampaignRegistrations(Number(id), 1, 50),
|
queryFn: () => campaignsApi.getCampaignRegistrations(numericId!, 1, 50),
|
||||||
enabled: !!id && showUsers,
|
enabled: isValidId && showUsers,
|
||||||
});
|
});
|
||||||
|
|
||||||
const copyBotLink = async () => {
|
// Fetch chart data
|
||||||
if (stats?.deep_link) {
|
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 {
|
try {
|
||||||
await navigator.clipboard.writeText(stats.deep_link);
|
await copyToClipboard(url);
|
||||||
|
haptic.notification('success');
|
||||||
|
if (type === 'bot') {
|
||||||
setCopiedBot(true);
|
setCopiedBot(true);
|
||||||
clearTimeout(copyBotTimer.current);
|
clearTimeout(copyBotTimer.current);
|
||||||
copyBotTimer.current = setTimeout(() => setCopiedBot(false), 2000);
|
copyBotTimer.current = setTimeout(
|
||||||
} catch {
|
() => setCopiedBot(false),
|
||||||
// Clipboard not available
|
PARTNER_STATS.COPY_FEEDBACK_MS,
|
||||||
}
|
);
|
||||||
}
|
} else {
|
||||||
};
|
|
||||||
|
|
||||||
const copyWebLink = async () => {
|
|
||||||
if (stats?.web_link) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(stats.web_link);
|
|
||||||
setCopiedWeb(true);
|
setCopiedWeb(true);
|
||||||
clearTimeout(copyWebTimer.current);
|
clearTimeout(copyWebTimer.current);
|
||||||
copyWebTimer.current = setTimeout(() => setCopiedWeb(false), 2000);
|
copyWebTimer.current = setTimeout(
|
||||||
|
() => setCopiedWeb(false),
|
||||||
|
PARTNER_STATS.COPY_FEEDBACK_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch {
|
} 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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -195,8 +207,8 @@ export default function AdminCampaignStats() {
|
|||||||
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
|
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
|
||||||
<ChartIcon />
|
<ChartIcon />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-xl font-semibold text-dark-100">{stats.name}</h1>
|
<h1 className="truncate text-xl font-semibold text-dark-100">{stats.name}</h1>
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`rounded px-2 py-0.5 text-xs ${bonusTypeConfig[stats.bonus_type].bgColor} ${bonusTypeConfig[stats.bonus_type].color}`}
|
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>
|
<span className="truncate text-sm text-dark-300">{stats.deep_link}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={copyBotLink}
|
onClick={() => handleCopy(stats.deep_link!, 'bot')}
|
||||||
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"
|
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 />
|
<CopyIcon />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
@@ -256,8 +268,8 @@ export default function AdminCampaignStats() {
|
|||||||
<span className="truncate text-sm text-dark-300">{stats.web_link}</span>
|
<span className="truncate text-sm text-dark-300">{stats.web_link}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={copyWebLink}
|
onClick={() => handleCopy(stats.web_link!, 'web')}
|
||||||
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"
|
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 />
|
<CopyIcon />
|
||||||
<span className="text-sm">
|
<span className="text-sm">
|
||||||
@@ -275,21 +287,25 @@ export default function AdminCampaignStats() {
|
|||||||
{/* Main Stats */}
|
{/* Main Stats */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<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="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 className="text-xs text-dark-500">{t('admin.campaigns.stats.registrations')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||||
<div className="text-2xl font-bold text-success-400">
|
<div className="truncate text-xl font-bold text-success-400 sm:text-2xl">
|
||||||
{formatRubles(stats.total_revenue_kopeks)}
|
{formatWithCurrency(stats.total_revenue_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.revenue')}</div>
|
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.revenue')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
<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 className="text-xs text-dark-500">{t('admin.campaigns.stats.paidUsers')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
<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 className="text-xs text-dark-500">{t('admin.campaigns.stats.conversion')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -306,7 +322,7 @@ export default function AdminCampaignStats() {
|
|||||||
</div>
|
</div>
|
||||||
{stats.bonus_type === 'balance' && (
|
{stats.bonus_type === 'balance' && (
|
||||||
<div className="text-lg font-medium text-success-400">
|
<div className="text-lg font-medium text-success-400">
|
||||||
{formatRubles(stats.balance_issued_kopeks)}
|
{formatWithCurrency(stats.balance_issued_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{stats.bonus_type === 'subscription' && (
|
{stats.bonus_type === 'subscription' && (
|
||||||
@@ -330,7 +346,9 @@ export default function AdminCampaignStats() {
|
|||||||
{t('admin.campaigns.stats.avgRevenuePerUser')}
|
{t('admin.campaigns.stats.avgRevenuePerUser')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-medium text-dark-200">
|
<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>
|
</div>
|
||||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||||
@@ -338,7 +356,7 @@ export default function AdminCampaignStats() {
|
|||||||
{t('admin.campaigns.stats.avgFirstPayment')}
|
{t('admin.campaigns.stats.avgFirstPayment')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-medium text-dark-200">
|
<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>
|
</div>
|
||||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||||
@@ -371,6 +389,94 @@ export default function AdminCampaignStats() {
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Users Section */}
|
||||||
<div className="rounded-xl border border-dark-700 bg-dark-800">
|
<div className="rounded-xl border border-dark-700 bg-dark-800">
|
||||||
<button
|
<button
|
||||||
@@ -414,7 +520,9 @@ export default function AdminCampaignStats() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-dark-100">
|
<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>
|
||||||
<div className="text-xs text-dark-500">{reg.telegram_id}</div>
|
<div className="text-xs text-dark-500">{reg.telegram_id}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -414,6 +414,13 @@ export default function AdminPanel() {
|
|||||||
description: t('admin.panel.trafficUsageDesc'),
|
description: t('admin.panel.trafficUsageDesc'),
|
||||||
permission: 'traffic:read',
|
permission: 'traffic:read',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
to: '/admin/sales-stats',
|
||||||
|
icon: <ChartBarIcon />,
|
||||||
|
title: t('admin.nav.salesStats'),
|
||||||
|
description: t('admin.panel.salesStatsDesc'),
|
||||||
|
permission: 'stats:read',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -232,10 +232,11 @@ export default function AdminPartnerDetail() {
|
|||||||
{partner.campaigns.map((campaign) => (
|
{partner.campaigns.map((campaign) => (
|
||||||
<div
|
<div
|
||||||
key={campaign.id}
|
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' : ''
|
!campaign.is_active ? 'opacity-60' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="font-medium text-dark-100">{campaign.name}</div>
|
<div className="font-medium text-dark-100">{campaign.name}</div>
|
||||||
<div className="font-mono text-xs text-dark-500">
|
<div className="font-mono text-xs text-dark-500">
|
||||||
@@ -274,6 +275,35 @@ export default function AdminPartnerDetail() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -242,6 +242,12 @@ export default function AdminPartners() {
|
|||||||
{app.expected_monthly_referrals}
|
{app.expected_monthly_referrals}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{app.desired_commission_percent != null && (
|
||||||
|
<div>
|
||||||
|
{t('admin.partners.applicationFields.desiredCommission')}:{' '}
|
||||||
|
{app.desired_commission_percent}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
|||||||
171
src/pages/AdminSalesStats.tsx
Normal file
171
src/pages/AdminSalesStats.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import type { SalesStatsParams } from '../api/adminSalesStats';
|
||||||
|
import { salesStatsApi } from '../api/adminSalesStats';
|
||||||
|
import { SALES_STATS } from '../constants/salesStats';
|
||||||
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
|
import { AdminBackButton } from '../components/admin/AdminBackButton';
|
||||||
|
import { StatCard } from '../components/stats';
|
||||||
|
import {
|
||||||
|
AddonsTab,
|
||||||
|
DepositsTab,
|
||||||
|
PeriodSelector,
|
||||||
|
RenewalsTab,
|
||||||
|
SalesTab,
|
||||||
|
TrialsTab,
|
||||||
|
} from '../components/sales-stats';
|
||||||
|
|
||||||
|
type TabId = 'trials' | 'sales' | 'renewals' | 'addons' | 'deposits';
|
||||||
|
|
||||||
|
export default function AdminSalesStats() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { formatWithCurrency } = useCurrency();
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>('trials');
|
||||||
|
const [period, setPeriod] = useState<{
|
||||||
|
days?: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}>({ days: SALES_STATS.DEFAULT_PERIOD });
|
||||||
|
|
||||||
|
const params: SalesStatsParams = useMemo(
|
||||||
|
() => ({
|
||||||
|
days: period.days,
|
||||||
|
start_date: period.startDate,
|
||||||
|
end_date: period.endDate,
|
||||||
|
}),
|
||||||
|
[period.days, period.startDate, period.endDate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isValidPeriod = period.days !== undefined || (!!period.startDate && !!period.endDate);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: summary,
|
||||||
|
isLoading: summaryLoading,
|
||||||
|
isError: summaryError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['sales-stats', 'summary', params],
|
||||||
|
queryFn: () => salesStatsApi.getSummary(params),
|
||||||
|
staleTime: SALES_STATS.STALE_TIME,
|
||||||
|
enabled: isValidPeriod,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs: { id: TabId; label: string }[] = [
|
||||||
|
{ id: 'trials', label: t('admin.salesStats.tabs.trials') },
|
||||||
|
{ id: 'sales', label: t('admin.salesStats.tabs.sales') },
|
||||||
|
{ id: 'renewals', label: t('admin.salesStats.tabs.renewals') },
|
||||||
|
{ id: 'addons', label: t('admin.salesStats.tabs.addons') },
|
||||||
|
{ id: 'deposits', label: t('admin.salesStats.tabs.deposits') },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="animate-fade-in space-y-4 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<AdminBackButton />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-dark-100 sm:text-2xl">
|
||||||
|
{t('admin.salesStats.title')}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-dark-400">{t('admin.salesStats.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Period selector */}
|
||||||
|
<PeriodSelector value={period} onChange={setPeriod} />
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
{summaryError && (
|
||||||
|
<div className="rounded-xl bg-error-500/10 px-4 py-3 text-sm text-error-400">
|
||||||
|
{t('admin.salesStats.loadError')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-7">
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.summary.revenue')}
|
||||||
|
value={
|
||||||
|
summaryLoading
|
||||||
|
? '...'
|
||||||
|
: formatWithCurrency(
|
||||||
|
(summary?.total_revenue_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
valueClassName="text-success-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.summary.activeSubs')}
|
||||||
|
value={summaryLoading ? '...' : (summary?.active_subscriptions ?? 0)}
|
||||||
|
valueClassName="text-accent-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.summary.activeTrials')}
|
||||||
|
value={summaryLoading ? '...' : (summary?.active_trials ?? 0)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.summary.newTrials')}
|
||||||
|
value={summaryLoading ? '...' : (summary?.new_trials ?? 0)}
|
||||||
|
valueClassName="text-blue-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.summary.conversion')}
|
||||||
|
value={summaryLoading ? '...' : `${summary?.trial_to_paid_conversion ?? 0}%`}
|
||||||
|
valueClassName="text-warning-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.summary.renewals')}
|
||||||
|
value={summaryLoading ? '...' : (summary?.renewals_count ?? 0)}
|
||||||
|
valueClassName="text-success-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.salesStats.summary.addonRevenue')}
|
||||||
|
value={
|
||||||
|
summaryLoading
|
||||||
|
? '...'
|
||||||
|
: formatWithCurrency(
|
||||||
|
(summary?.addon_revenue_kopeks ?? 0) / SALES_STATS.KOPEKS_DIVISOR,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
valueClassName="text-accent-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div
|
||||||
|
className="scrollbar-hide flex gap-1 overflow-x-auto rounded-xl bg-dark-800/30 p-1"
|
||||||
|
role="tablist"
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
id={`tab-${tab.id}`}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={activeTab === tab.id}
|
||||||
|
aria-controls={`panel-${tab.id}`}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`shrink-0 whitespace-nowrap rounded-lg px-3 py-2.5 text-xs font-medium transition-colors sm:text-sm ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-dark-700/60 text-dark-100'
|
||||||
|
: 'text-dark-400 hover:text-dark-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab content */}
|
||||||
|
{isValidPeriod && (
|
||||||
|
<div role="tabpanel" id={`panel-${activeTab}`} aria-labelledby={`tab-${activeTab}`}>
|
||||||
|
{activeTab === 'trials' && <TrialsTab params={params} />}
|
||||||
|
{activeTab === 'sales' && <SalesTab params={params} />}
|
||||||
|
{activeTab === 'renewals' && <RenewalsTab params={params} />}
|
||||||
|
{activeTab === 'addons' && <AddonsTab params={params} />}
|
||||||
|
{activeTab === 'deposits' && <DepositsTab params={params} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -126,6 +126,14 @@ export default function AdminWithdrawalDetail() {
|
|||||||
<h3 className="mb-4 font-medium text-dark-200">
|
<h3 className="mb-4 font-medium text-dark-200">
|
||||||
{t('admin.withdrawals.detail.userInfo')}
|
{t('admin.withdrawals.detail.userInfo')}
|
||||||
</h3>
|
</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="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||||
<div className="mb-1 text-sm text-dark-400">
|
<div className="mb-1 text-sm text-dark-400">
|
||||||
|
|||||||
@@ -35,6 +35,15 @@ export default function Connection() {
|
|||||||
navigate(-1);
|
navigate(-1);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleOpenQR = useCallback(() => {
|
||||||
|
navigate('/connection/qr', {
|
||||||
|
state: {
|
||||||
|
url: appConfig?.subscriptionUrl,
|
||||||
|
hideLink: appConfig?.hideLink ?? false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [navigate, appConfig?.subscriptionUrl, appConfig?.hideLink]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@@ -184,6 +193,7 @@ export default function Connection() {
|
|||||||
onOpenDeepLink={openDeepLink}
|
onOpenDeepLink={openDeepLink}
|
||||||
isTelegramWebApp={isTelegramWebApp}
|
isTelegramWebApp={isTelegramWebApp}
|
||||||
onGoBack={handleGoBack}
|
onGoBack={handleGoBack}
|
||||||
|
onOpenQR={handleOpenQR}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
116
src/pages/ConnectionQR.tsx
Normal file
116
src/pages/ConnectionQR.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { useBranding } from '../hooks/useBranding';
|
||||||
|
|
||||||
|
interface ConnectionQRState {
|
||||||
|
url: string;
|
||||||
|
hideLink: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidState(state: unknown): state is ConnectionQRState {
|
||||||
|
if (!state || typeof state !== 'object') return false;
|
||||||
|
const s = state as Record<string, unknown>;
|
||||||
|
return typeof s.url === 'string' && s.url.length > 0 && typeof s.hideLink === 'boolean';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConnectionQR() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
const isLoading = useAuthStore((state) => state.isLoading);
|
||||||
|
const { appName } = useBranding();
|
||||||
|
|
||||||
|
const state = location.state as unknown;
|
||||||
|
const validState = isValidState(state) ? state : null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && !isAuthenticated) {
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isLoading, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && isAuthenticated && !validState) {
|
||||||
|
navigate('/connection', { replace: true });
|
||||||
|
}
|
||||||
|
}, [isLoading, isAuthenticated, validState, navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
navigate(-1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
if (isLoading || !validState) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex min-h-dvh flex-col items-center justify-center bg-gradient-to-b from-dark-900 to-dark-950">
|
||||||
|
{/* Close button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="absolute left-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-dark-800/60 backdrop-blur-sm transition-colors hover:bg-dark-700/80"
|
||||||
|
aria-label={t('common.close')}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 text-dark-200"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex w-full max-w-sm animate-scale-in flex-col items-center px-6">
|
||||||
|
{/* Branding name */}
|
||||||
|
{appName && (
|
||||||
|
<p className="mb-3 text-sm font-medium uppercase tracking-wider text-dark-400">
|
||||||
|
{appName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="mb-2 text-center text-xl font-bold text-dark-100">
|
||||||
|
{t('subscription.connection.qrTitle')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Hint */}
|
||||||
|
<p className="mb-8 text-center text-sm text-dark-400">
|
||||||
|
{t('subscription.connection.qrScanHint')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* QR code container */}
|
||||||
|
<div className="rounded-3xl bg-white p-6">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={validState.url}
|
||||||
|
size={280}
|
||||||
|
level="M"
|
||||||
|
includeMargin={false}
|
||||||
|
className="h-[280px] w-[280px] sm:h-[360px] sm:w-[360px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL display */}
|
||||||
|
{!validState.hideLink && (
|
||||||
|
<p className="mt-6 max-w-full truncate text-center font-mono text-xs text-dark-500">
|
||||||
|
{validState.url}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -265,7 +265,7 @@ export default function Dashboard() {
|
|||||||
balanceRubles={balanceData?.balance_rubles || 0}
|
balanceRubles={balanceData?.balance_rubles || 0}
|
||||||
subscription={subscription}
|
subscription={subscription}
|
||||||
referralCount={referralInfo?.total_referrals || 0}
|
referralCount={referralInfo?.total_referrals || 0}
|
||||||
earningsRubles={referralInfo?.total_earnings_rubles || 0}
|
earningsRubles={referralInfo?.available_balance_rubles || 0}
|
||||||
refLoading={refLoading}
|
refLoading={refLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -127,12 +127,13 @@ export default function Login() {
|
|||||||
const { authorize_url, state } = await authApi.getOAuthAuthorizeUrl(provider);
|
const { authorize_url, state } = await authApi.getOAuthAuthorizeUrl(provider);
|
||||||
|
|
||||||
// Validate redirect URL — only allow HTTPS to prevent open redirect
|
// Validate redirect URL — only allow HTTPS to prevent open redirect
|
||||||
|
let parsed: URL;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(authorize_url);
|
parsed = new URL(authorize_url);
|
||||||
if (parsed.protocol !== 'https:') {
|
} catch {
|
||||||
throw new Error('Invalid OAuth redirect URL');
|
throw new Error('Invalid OAuth redirect URL');
|
||||||
}
|
}
|
||||||
} catch {
|
if (parsed.protocol !== 'https:') {
|
||||||
throw new Error('Invalid OAuth redirect URL');
|
throw new Error('Invalid OAuth redirect URL');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router';
|
import { useNavigate, useSearchParams } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
@@ -28,6 +28,7 @@ export default function OAuthCallback() {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth);
|
const loginWithOAuth = useAuthStore((state) => state.loginWithOAuth);
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
const hasRun = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
@@ -35,9 +36,15 @@ export default function OAuthCallback() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prevent double-fire from React StrictMode or dependency changes
|
||||||
|
if (hasRun.current) return;
|
||||||
|
hasRun.current = true;
|
||||||
|
|
||||||
const authenticate = async () => {
|
const authenticate = async () => {
|
||||||
const code = searchParams.get('code');
|
const code = searchParams.get('code');
|
||||||
const urlState = searchParams.get('state');
|
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) {
|
if (!code || !urlState) {
|
||||||
setError(t('auth.oauthError', 'Authorization was denied or failed'));
|
setError(t('auth.oauthError', 'Authorization was denied or failed'));
|
||||||
@@ -58,14 +65,13 @@ export default function OAuthCallback() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await loginWithOAuth(saved.provider, code, urlState);
|
await loginWithOAuth(saved.provider, code, urlState, deviceId);
|
||||||
navigate('/', { replace: true });
|
navigate('/', { replace: true });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const error = err as { response?: { data?: { detail?: string } } };
|
const detail =
|
||||||
setError(
|
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail ??
|
||||||
error.response?.data?.detail ||
|
(err instanceof Error ? err.message : null);
|
||||||
t('auth.oauthError', 'Authorization was denied or failed'),
|
setError(detail || t('auth.oauthError', 'Authorization was denied or failed'));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { referralApi } from '../api/referral';
|
import { referralApi } from '../api/referral';
|
||||||
|
import { copyToClipboard } from '../utils/clipboard';
|
||||||
import { brandingApi } from '../api/branding';
|
import { brandingApi } from '../api/branding';
|
||||||
import { partnerApi } from '../api/partners';
|
import { partnerApi } from '../api/partners';
|
||||||
import { withdrawalApi } from '../api/withdrawals';
|
import { withdrawalApi } from '../api/withdrawals';
|
||||||
|
import { CampaignCard } from '../components/partner/CampaignCard';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
|
|
||||||
const LinkIcon = () => (
|
const LinkIcon = () => (
|
||||||
@@ -88,12 +90,18 @@ function getWithdrawalStatusBadge(status: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Referral() {
|
export default function Referral() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { formatAmount, currencySymbol, formatPositive, formatWithCurrency } = useCurrency();
|
const { formatAmount, currencySymbol, formatPositive, formatWithCurrency } = useCurrency();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [copied, setCopied] = useState(false);
|
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({
|
const { data: info, isLoading } = useQuery({
|
||||||
queryKey: ['referral-info'],
|
queryKey: ['referral-info'],
|
||||||
@@ -156,11 +164,15 @@ export default function Referral() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const copyLink = () => {
|
const copyLink = async () => {
|
||||||
if (referralLink) {
|
if (!referralLink) return;
|
||||||
navigator.clipboard.writeText(referralLink);
|
try {
|
||||||
|
await copyToClipboard(referralLink);
|
||||||
setCopied(true);
|
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>
|
||||||
<div className="font-medium text-dark-100">
|
<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>
|
||||||
<div className="mt-0.5 text-xs text-dark-500">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{ref.has_paid ? (
|
{ref.has_paid ? (
|
||||||
@@ -384,11 +396,13 @@ export default function Referral() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-dark-100">
|
<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>
|
||||||
<div className="mt-0.5 text-xs text-dark-500">
|
<div className="mt-0.5 text-xs text-dark-500">
|
||||||
{t(`referral.reasons.${earning.reason}`, earning.reason)} •{' '}
|
{t(`referral.reasons.${earning.reason}`, earning.reason)} •{' '}
|
||||||
{new Date(earning.created_at).toLocaleDateString()}
|
{new Date(earning.created_at).toLocaleDateString(i18n.language)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="font-semibold text-success-400">
|
<div className="font-semibold text-success-400">
|
||||||
@@ -442,9 +456,9 @@ export default function Referral() {
|
|||||||
{partnerStatus?.latest_application?.created_at && (
|
{partnerStatus?.latest_application?.created_at && (
|
||||||
<p className="mt-2 text-xs text-dark-500">
|
<p className="mt-2 text-xs text-dark-500">
|
||||||
{t('referral.partner.submittedAt', {
|
{t('referral.partner.submittedAt', {
|
||||||
date: new Date(
|
date: new Date(partnerStatus.latest_application.created_at).toLocaleDateString(
|
||||||
partnerStatus.latest_application.created_at,
|
i18n.language,
|
||||||
).toLocaleDateString(),
|
),
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -535,99 +549,9 @@ export default function Referral() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{partnerStatus.campaigns.map((campaign) => {
|
{partnerStatus.campaigns.map((campaign) => (
|
||||||
const copyLink = (url: string, key: string) => {
|
<CampaignCard key={campaign.id} campaign={campaign} />
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -731,7 +655,7 @@ export default function Referral() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 text-xs text-dark-500">
|
<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 && (
|
{item.payment_details && (
|
||||||
<span className="ml-1">
|
<span className="ml-1">
|
||||||
•{' '}
|
•{' '}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default function ReferralPartnerApply() {
|
|||||||
telegram_channel: '',
|
telegram_channel: '',
|
||||||
description: '',
|
description: '',
|
||||||
expected_monthly_referrals: undefined,
|
expected_monthly_referrals: undefined,
|
||||||
|
desired_commission_percent: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Guard: redirect if already approved or pending
|
// Guard: redirect if already approved or pending
|
||||||
@@ -50,6 +51,9 @@ export default function ReferralPartnerApply() {
|
|||||||
if (form.expected_monthly_referrals) {
|
if (form.expected_monthly_referrals) {
|
||||||
payload.expected_monthly_referrals = 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);
|
applyMutation.mutate(payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,6 +130,25 @@ export default function ReferralPartnerApply() {
|
|||||||
placeholder={t('referral.partner.fields.expectedReferralsPlaceholder')}
|
placeholder={t('referral.partner.fields.expectedReferralsPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{applyMutation.isError && (
|
{applyMutation.isError && (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export default function ReferralWithdrawalRequest() {
|
|||||||
const { formatWithCurrency, currencySymbol } = useCurrency();
|
const { formatWithCurrency, currencySymbol } = useCurrency();
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
amount_kopeks: 0,
|
amount_rubles: 0,
|
||||||
payment_details: '',
|
payment_details: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,9 +40,9 @@ export default function ReferralWithdrawalRequest() {
|
|||||||
const handleSubmit = (e: FormEvent) => {
|
const handleSubmit = (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (form.payment_details.length < 5) return;
|
if (form.payment_details.length < 5) return;
|
||||||
if (form.amount_kopeks <= 0) return;
|
if (form.amount_rubles <= 0) return;
|
||||||
withdrawMutation.mutate({
|
withdrawMutation.mutate({
|
||||||
amount_kopeks: form.amount_kopeks,
|
amount_kopeks: Math.round(form.amount_rubles * 100),
|
||||||
payment_details: form.payment_details,
|
payment_details: form.payment_details,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -64,21 +64,25 @@ export default function ReferralWithdrawalRequest() {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min={balance?.min_amount_kopeks ?? 0}
|
min={balance ? Math.ceil(balance.min_amount_kopeks / 100) : 0}
|
||||||
max={balance?.available_total ?? 0}
|
max={balance ? Math.floor(balance.available_total / 100) : 0}
|
||||||
step={100}
|
|
||||||
className="input w-full"
|
className="input w-full"
|
||||||
value={form.amount_kopeks || ''}
|
value={form.amount_rubles || ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setForm({
|
setForm({
|
||||||
...form,
|
...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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -115,12 +119,12 @@ export default function ReferralWithdrawalRequest() {
|
|||||||
disabled={
|
disabled={
|
||||||
withdrawMutation.isPending ||
|
withdrawMutation.isPending ||
|
||||||
form.payment_details.length < 5 ||
|
form.payment_details.length < 5 ||
|
||||||
form.amount_kopeks <= 0
|
form.amount_rubles <= 0
|
||||||
}
|
}
|
||||||
className={`btn-primary flex-1 px-5 ${
|
className={`btn-primary flex-1 px-5 ${
|
||||||
withdrawMutation.isPending ||
|
withdrawMutation.isPending ||
|
||||||
form.payment_details.length < 5 ||
|
form.payment_details.length < 5 ||
|
||||||
form.amount_kopeks <= 0
|
form.amount_rubles <= 0
|
||||||
? 'opacity-50'
|
? 'opacity-50'
|
||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1903
src/pages/SubscriptionPurchase.tsx
Normal file
1903
src/pages/SubscriptionPurchase.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -403,6 +403,7 @@ export default function Wheel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUnifiedSpin = () => {
|
const handleUnifiedSpin = () => {
|
||||||
|
if (noSubscription) return;
|
||||||
if (paymentType === 'telegram_stars') {
|
if (paymentType === 'telegram_stars') {
|
||||||
if (!config?.spin_cost_stars_enabled || !config?.spin_cost_stars) {
|
if (!config?.spin_cost_stars_enabled || !config?.spin_cost_stars) {
|
||||||
notify.warning(t('wheel.starsNotAvailable'));
|
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
|
// 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 dailyLimitReached = config.daily_limit > 0 && config.user_spins_today >= config.daily_limit;
|
||||||
|
const noSubscription = !config.has_subscription;
|
||||||
const spinDisabled =
|
const spinDisabled =
|
||||||
isSpinning ||
|
isSpinning ||
|
||||||
isPayingStars ||
|
isPayingStars ||
|
||||||
dailyLimitReached ||
|
dailyLimitReached ||
|
||||||
|
noSubscription ||
|
||||||
(paymentType === 'telegram_stars' ? !starsEnabled : !config.can_spin);
|
(paymentType === 'telegram_stars' ? !starsEnabled : !config.can_spin);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -614,8 +617,17 @@ export default function Wheel() {
|
|||||||
</Button>
|
</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) */}
|
{/* 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">
|
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
|
||||||
<p className="text-dark-400">
|
<p className="text-dark-400">
|
||||||
{config.can_spin_reason === 'daily_limit_reached'
|
{config.can_spin_reason === 'daily_limit_reached'
|
||||||
@@ -625,7 +637,10 @@ export default function Wheel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Daily limit hint for Stars payment (not covered by can_spin check) */}
|
{/* 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">
|
<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>
|
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ interface AuthState {
|
|||||||
loginWithTelegram: (initData: string) => Promise<void>;
|
loginWithTelegram: (initData: string) => Promise<void>;
|
||||||
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
|
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
|
||||||
loginWithEmail: (email: string, password: string) => 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: (
|
registerWithEmail: (
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
@@ -295,13 +300,14 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
await get().checkAdminStatus();
|
await get().checkAdminStatus();
|
||||||
},
|
},
|
||||||
|
|
||||||
loginWithOAuth: async (provider, code, state) => {
|
loginWithOAuth: async (provider, code, state, deviceId) => {
|
||||||
const campaignSlug = consumeCampaignSlug();
|
const campaignSlug = consumeCampaignSlug();
|
||||||
const referralCode = consumeReferralCode();
|
const referralCode = consumeReferralCode();
|
||||||
const response = await authApi.oauthCallback(
|
const response = await authApi.oauthCallback(
|
||||||
provider,
|
provider,
|
||||||
code,
|
code,
|
||||||
state,
|
state,
|
||||||
|
deviceId,
|
||||||
campaignSlug,
|
campaignSlug,
|
||||||
referralCode,
|
referralCode,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
animation: border-rotate var(--border-duration, 3s) linear infinite;
|
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:hover,
|
||||||
.hover-border-gradient:active {
|
.hover-border-gradient:active {
|
||||||
box-shadow: 0 0 20px var(--accent-glow, rgba(var(--color-accent-400), 0.25));
|
box-shadow: 0 0 20px var(--accent-glow, rgba(var(--color-accent-400), 0.25));
|
||||||
@@ -251,9 +255,11 @@
|
|||||||
content: '';
|
content: '';
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 0;
|
z-index: -1;
|
||||||
pointer-events: none;
|
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-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;
|
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 {
|
main {
|
||||||
/* Prevent layout shifts during scroll */
|
contain: content;
|
||||||
contain: layout style;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light theme - Champagne */
|
/* Light theme - Champagne */
|
||||||
@@ -345,7 +351,6 @@ img.twemoji {
|
|||||||
@apply border border-dark-700/40 bg-dark-900/70;
|
@apply border border-dark-700/40 bg-dark-900/70;
|
||||||
border-radius: var(--bento-radius);
|
border-radius: var(--bento-radius);
|
||||||
padding: var(--bento-padding);
|
padding: var(--bento-padding);
|
||||||
transform: translateZ(0);
|
|
||||||
/* Glass Border - Inset Highlight */
|
/* Glass Border - Inset Highlight */
|
||||||
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||||
/* Stagger animation support */
|
/* Stagger animation support */
|
||||||
@@ -357,7 +362,7 @@ img.twemoji {
|
|||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.bento-card {
|
.bento-card {
|
||||||
@apply bg-dark-900/50 backdrop-blur-sm;
|
@apply bg-dark-900/60;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,12 +375,16 @@ img.twemoji {
|
|||||||
|
|
||||||
.bento-card-hover {
|
.bento-card-hover {
|
||||||
@apply bento-card cursor-pointer;
|
@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 {
|
.bento-card-hover:hover {
|
||||||
@apply border-dark-600/50 bg-dark-800/60 shadow-lg;
|
@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 */
|
/* Intensify Glass Border & Add Top Spotlight */
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px 0 0 rgba(255, 255, 255, 0.1),
|
inset 0 1px 0 0 rgba(255, 255, 255, 0.1),
|
||||||
@@ -399,22 +408,25 @@ img.twemoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bento-card-hover:active {
|
.bento-card-hover:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98) translateZ(0);
|
||||||
transition-duration: 0.15s;
|
transition-duration: 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bento-card-glow {
|
.bento-card-glow {
|
||||||
@apply bento-card cursor-pointer;
|
@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 {
|
.bento-card-glow:hover {
|
||||||
@apply border-accent-500/30 shadow-glow;
|
@apply border-accent-500/30 shadow-glow;
|
||||||
transform: translateY(-4px);
|
transform: translateY(-2px) translateZ(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bento-card-glow:active {
|
.bento-card-glow:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98) translateZ(0);
|
||||||
transition-duration: 0.15s;
|
transition-duration: 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,13 +457,13 @@ img.twemoji {
|
|||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.light .bento-card {
|
.light .bento-card {
|
||||||
@apply bg-champagne-50/80 backdrop-blur-sm;
|
@apply bg-champagne-50/85;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.light .bento-card-hover:hover {
|
.light .bento-card-hover:hover {
|
||||||
@apply border-champagne-400/50 bg-champagne-50 shadow-md;
|
@apply border-champagne-400/50 bg-champagne-50 shadow-md;
|
||||||
transform: translateY(-4px);
|
transform: translateY(-2px) translateZ(0);
|
||||||
/* Intensify Light Theme Glass Border */
|
/* Intensify Light Theme Glass Border */
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px 0 0 rgba(0, 0, 0, 0.06),
|
inset 0 1px 0 0 rgba(0, 0, 0, 0.06),
|
||||||
@@ -465,23 +477,21 @@ img.twemoji {
|
|||||||
|
|
||||||
.light .bento-card-glow:hover {
|
.light .bento-card-glow:hover {
|
||||||
@apply border-accent-400/40 shadow-lg;
|
@apply border-accent-400/40 shadow-lg;
|
||||||
transform: translateY(-4px);
|
transform: translateY(-2px) translateZ(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ========== DARK THEME COMPONENTS (default) ========== */
|
/* ========== DARK THEME COMPONENTS (default) ========== */
|
||||||
|
|
||||||
/* Cards - Dark (unified style) */
|
/* Cards - Dark (unified style) */
|
||||||
.card {
|
.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);
|
border-radius: var(--bento-radius);
|
||||||
transform: translateZ(0);
|
|
||||||
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enable backdrop-blur only on desktop */
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.card {
|
.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 effect - Dark (optimized for mobile) */
|
||||||
.glass {
|
.glass {
|
||||||
@apply border border-dark-700/30 bg-dark-900/80;
|
@apply border border-dark-700/30 bg-dark-900/80;
|
||||||
transform: translateZ(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enable backdrop-blur only on desktop where it's performant */
|
/* Enable backdrop-blur only on desktop where it's performant */
|
||||||
@@ -512,7 +521,8 @@ img.twemoji {
|
|||||||
|
|
||||||
/* Buttons - Dark */
|
/* Buttons - Dark */
|
||||||
.btn {
|
.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 {
|
.btn-primary {
|
||||||
@@ -532,7 +542,8 @@ img.twemoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.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 */
|
/* Highlighted button for onboarding */
|
||||||
@@ -542,7 +553,8 @@ img.twemoji {
|
|||||||
|
|
||||||
/* Inputs - Dark */
|
/* Inputs - Dark */
|
||||||
.input {
|
.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 {
|
.input-error {
|
||||||
@@ -605,7 +617,8 @@ img.twemoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
.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 */
|
/* Stat card - Dark */
|
||||||
@@ -619,7 +632,8 @@ img.twemoji {
|
|||||||
|
|
||||||
/* Navigation - Dark */
|
/* Navigation - Dark */
|
||||||
.nav-item {
|
.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 {
|
.nav-item-active {
|
||||||
@@ -646,7 +660,8 @@ img.twemoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav-item {
|
.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 {
|
.bottom-nav-item:hover {
|
||||||
@@ -654,7 +669,8 @@ img.twemoji {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bottom-nav-item-active {
|
.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 */
|
/* Divider - Dark */
|
||||||
@@ -671,7 +687,8 @@ img.twemoji {
|
|||||||
.backdrop-blur-lg,
|
.backdrop-blur-lg,
|
||||||
.backdrop-blur-xl,
|
.backdrop-blur-xl,
|
||||||
.backdrop-blur-2xl,
|
.backdrop-blur-2xl,
|
||||||
.backdrop-blur-3xl {
|
.backdrop-blur-3xl,
|
||||||
|
.backdrop-blur-linear {
|
||||||
backdrop-filter: none !important;
|
backdrop-filter: none !important;
|
||||||
-webkit-backdrop-filter: none !important;
|
-webkit-backdrop-filter: none !important;
|
||||||
}
|
}
|
||||||
@@ -683,13 +700,12 @@ img.twemoji {
|
|||||||
.light .card {
|
.light .card {
|
||||||
@apply border-champagne-300/50 bg-champagne-50/90 p-5 shadow-sm sm:p-6;
|
@apply border-champagne-300/50 bg-champagne-50/90 p-5 shadow-sm sm:p-6;
|
||||||
border-radius: var(--bento-radius);
|
border-radius: var(--bento-radius);
|
||||||
transform: translateZ(0);
|
|
||||||
box-shadow: inset 0 1px 0 0 rgba(0, 0, 0, 0.03);
|
box-shadow: inset 0 1px 0 0 rgba(0, 0, 0, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.light .card {
|
.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) */
|
/* Glass effect - Light (optimized for mobile) */
|
||||||
.light .glass {
|
.light .glass {
|
||||||
@apply border border-champagne-300/40 bg-champagne-50/90;
|
@apply border border-champagne-300/40 bg-champagne-50/90;
|
||||||
transform: translateZ(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
@@ -1098,7 +1113,7 @@ img.twemoji {
|
|||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1136,7 +1151,7 @@ img.twemoji {
|
|||||||
.btn:active,
|
.btn:active,
|
||||||
.card-hover:active,
|
.card-hover:active,
|
||||||
.nav-item: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;
|
border-radius: 0.375rem;
|
||||||
background-color: rgb(var(--color-dark-700));
|
background-color: rgb(var(--color-dark-700));
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s ease;
|
transition:
|
||||||
|
border-color 0.15s ease,
|
||||||
|
background-color 0.15s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -423,6 +423,9 @@ export interface ReferralInfo {
|
|||||||
total_earnings_kopeks: number;
|
total_earnings_kopeks: number;
|
||||||
total_earnings_rubles: number;
|
total_earnings_rubles: number;
|
||||||
commission_percent: number;
|
commission_percent: number;
|
||||||
|
available_balance_kopeks: number;
|
||||||
|
available_balance_rubles: number;
|
||||||
|
withdrawn_kopeks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReferralTerms {
|
export interface ReferralTerms {
|
||||||
|
|||||||
24
src/utils/clipboard.ts
Normal file
24
src/utils/clipboard.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Cross-platform clipboard utility with fallback for Telegram WebView.
|
||||||
|
* navigator.clipboard.writeText() is unavailable in some WebViews
|
||||||
|
* (Android System WebView, unfocused tabs, insecure contexts).
|
||||||
|
*/
|
||||||
|
export async function copyToClipboard(text: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.opacity = '0';
|
||||||
|
textarea.style.left = '-9999px';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
try {
|
||||||
|
textarea.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
if (!ok) throw new Error('execCommand copy failed');
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,8 @@ export function getGlassColors(isDark: boolean) {
|
|||||||
// Card container
|
// Card container
|
||||||
cardBg: isDark
|
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.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%)',
|
: '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.08)',
|
cardBorder: isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.1)',
|
||||||
|
|
||||||
// Inner sections (cards within cards)
|
// Inner sections (cards within cards)
|
||||||
innerBg: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)',
|
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',
|
glowAlpha: isDark ? '15' : '08',
|
||||||
|
|
||||||
// Shadows for light mode depth
|
// Shadows for light mode depth
|
||||||
shadow: isDark ? 'none' : '0 2px 12px rgba(0,0,0,0.06)',
|
shadow: isDark ? 'none' : '0 2px 16px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.03)',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/utils/subscriptionHelpers.ts
Normal file
42
src/utils/subscriptionHelpers.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import i18n from '../i18n';
|
||||||
|
|
||||||
|
export type PurchaseStep = 'period' | 'traffic' | 'servers' | 'devices' | 'confirm';
|
||||||
|
|
||||||
|
export const getErrorMessage = (error: unknown): string => {
|
||||||
|
if (error instanceof AxiosError) {
|
||||||
|
const detail = error.response?.data?.detail;
|
||||||
|
if (typeof detail === 'string') return detail;
|
||||||
|
if (typeof detail === 'object' && detail?.message) return detail.message;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
return i18n.t('common.error');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getInsufficientBalanceError = (
|
||||||
|
error: unknown,
|
||||||
|
): { required: number; balance: number; missingAmount?: number } | null => {
|
||||||
|
if (error instanceof AxiosError) {
|
||||||
|
const detail = error.response?.data?.detail;
|
||||||
|
if (
|
||||||
|
typeof detail === 'object' &&
|
||||||
|
(detail?.code === 'insufficient_balance' || detail?.code === 'insufficient_funds')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
required: detail.required || detail.total_price || 0,
|
||||||
|
balance: detail.balance || 0,
|
||||||
|
missingAmount: detail.missing_amount || detail.missingAmount || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getFlagEmoji = (countryCode: string): string => {
|
||||||
|
if (!countryCode || countryCode.length !== 2) return '';
|
||||||
|
const codePoints = countryCode
|
||||||
|
.toUpperCase()
|
||||||
|
.split('')
|
||||||
|
.map((char) => 127397 + char.charCodeAt(0));
|
||||||
|
return String.fromCodePoint(...codePoints);
|
||||||
|
};
|
||||||
12
src/vite-env.d.ts
vendored
12
src/vite-env.d.ts
vendored
@@ -12,3 +12,15 @@ interface ImportMetaEnv {
|
|||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv;
|
readonly env: ImportMetaEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Telegram WebApp global — available when running inside Telegram Mini App */
|
||||||
|
interface TelegramWebAppGlobal {
|
||||||
|
onEvent?: (event: string, callback: () => void) => void;
|
||||||
|
offEvent?: (event: string, callback: () => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
Telegram?: {
|
||||||
|
WebApp?: TelegramWebAppGlobal;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user