mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
feat: add recharts analytics to admin campaign stats page
- Move shared chart components (DailyChart, PeriodComparison, StatCard) to src/components/stats/ with configurable label props - Create shared types for chart data decoupled from partner API - Add campaign chart data API client with deposits/spending split - Enhance AdminCampaignStats with daily trends chart, period comparison, top registrations, and deposit/spending stat cards - Add useChartColors hook with SSR guard and theme-aware CSS variable parsing - Add cross-platform clipboard utility with execCommand fallback for Telegram WebView - Add i18n keys for chart analytics across all 4 locales (en, ru, zh, fa) - Responsive fixes: truncation for long names/values, responsive text sizing, touch-friendly copy buttons
This commit is contained in:
384
package-lock.json
generated
384
package-lock.json
generated
@@ -42,6 +42,7 @@
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-router": "^7.13.0",
|
||||
"react-twemoji": "^0.7.2",
|
||||
"recharts": "^3.7.0",
|
||||
"simplex-noise": "^4.0.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.11"
|
||||
@@ -2245,6 +2246,42 @@
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.2",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
|
||||
@@ -2602,6 +2639,18 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.20",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz",
|
||||
@@ -2827,6 +2876,69 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dompurify": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||
@@ -2898,6 +3010,12 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz",
|
||||
@@ -3757,6 +3875,127 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -3775,6 +4014,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3920,6 +4165,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
|
||||
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
@@ -4188,7 +4443,6 @@
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
@@ -4693,6 +4947,16 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
@@ -4720,6 +4984,15 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -5752,6 +6025,36 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
@@ -5893,6 +6196,57 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.7.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
|
||||
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -6317,6 +6671,12 @@
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -6584,6 +6944,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-router": "^7.13.0",
|
||||
"react-twemoji": "^0.7.2",
|
||||
"recharts": "^3.7.0",
|
||||
"simplex-noise": "^4.0.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.11"
|
||||
|
||||
@@ -149,6 +149,49 @@ export interface CampaignsOverview {
|
||||
total_tariff_issued: number;
|
||||
}
|
||||
|
||||
export interface AdminDailyStatItem {
|
||||
date: string;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface AdminPeriodStats {
|
||||
days: number;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface AdminPeriodChange {
|
||||
absolute: number;
|
||||
percent: number;
|
||||
trend: 'up' | 'down' | 'stable';
|
||||
}
|
||||
|
||||
export interface AdminPeriodComparison {
|
||||
current: AdminPeriodStats;
|
||||
previous: AdminPeriodStats;
|
||||
referrals_change: AdminPeriodChange;
|
||||
earnings_change: AdminPeriodChange;
|
||||
}
|
||||
|
||||
export interface AdminTopRegistrationItem {
|
||||
id: number;
|
||||
full_name: string;
|
||||
created_at: string;
|
||||
has_paid: boolean;
|
||||
is_active: boolean;
|
||||
total_earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface AdminCampaignChartData {
|
||||
campaign_id: number;
|
||||
total_deposits_kopeks: number;
|
||||
total_spending_kopeks: number;
|
||||
daily_stats: AdminDailyStatItem[];
|
||||
period_comparison: AdminPeriodComparison;
|
||||
top_registrations: AdminTopRegistrationItem[];
|
||||
}
|
||||
|
||||
export interface ServerSquadInfo {
|
||||
id: number;
|
||||
squad_uuid: string;
|
||||
@@ -258,4 +301,10 @@ export const campaignsApi = {
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns/available-partners');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get campaign chart data for analytics
|
||||
getChartData: async (campaignId: number): Promise<AdminCampaignChartData> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/chart-data`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface PartnerCampaignInfo {
|
||||
subscription_traffic_gb: number | null;
|
||||
deep_link: string | null;
|
||||
web_link: string | null;
|
||||
// Per-campaign statistics
|
||||
registrations_count: number;
|
||||
referrals_count: number;
|
||||
earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface PartnerStatusResponse {
|
||||
@@ -45,6 +49,57 @@ export interface PartnerApplicationRequest {
|
||||
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 ====================
|
||||
|
||||
export interface AdminPartnerApplicationItem {
|
||||
@@ -159,6 +214,13 @@ export const partnerApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getCampaignStats: async (campaignId: number): Promise<PartnerCampaignDetailedStats> => {
|
||||
const response = await apiClient.get<PartnerCampaignDetailedStats>(
|
||||
`/cabinet/referral/partner/campaigns/${campaignId}/stats`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Admin endpoints
|
||||
getStats: async (): Promise<PartnerStats> => {
|
||||
const response = await apiClient.get<PartnerStats>('/cabinet/admin/partners/stats');
|
||||
|
||||
197
src/components/partner/CampaignCard.tsx
Normal file
197
src/components/partner/CampaignCard.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
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');
|
||||
} catch {
|
||||
haptic.notification('error');
|
||||
}
|
||||
setCopiedLink(key);
|
||||
clearTimeout(copyTimerRef.current);
|
||||
copyTimerRef.current = setTimeout(() => setCopiedLink(null), PARTNER_STATS.COPY_FEEDBACK_MS);
|
||||
},
|
||||
[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>
|
||||
);
|
||||
}
|
||||
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).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>
|
||||
);
|
||||
}
|
||||
87
src/components/stats/PeriodComparison.tsx
Normal file
87
src/components/stats/PeriodComparison.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { PeriodComparison as PeriodComparisonType } from './types';
|
||||
import { PARTNER_STATS } from '../../constants/partner';
|
||||
import { useCurrency } from '../../hooks/useCurrency';
|
||||
|
||||
interface PeriodComparisonProps {
|
||||
data: PeriodComparisonType;
|
||||
title?: string;
|
||||
countLabel?: string;
|
||||
earningsLabel?: string;
|
||||
comparisonLabel?: string;
|
||||
}
|
||||
|
||||
const TREND_ARROW_UP = '\u2191';
|
||||
const TREND_ARROW_DOWN = '\u2193';
|
||||
const TREND_ARROW_STABLE = '\u2192';
|
||||
|
||||
const TREND_STYLES = {
|
||||
up: { arrow: TREND_ARROW_UP, className: 'text-success-400' },
|
||||
down: { arrow: TREND_ARROW_DOWN, className: 'text-error-400' },
|
||||
stable: { arrow: TREND_ARROW_STABLE, className: 'text-dark-400' },
|
||||
} as const;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
27
src/components/stats/StatCard.tsx
Normal file
27
src/components/stats/StatCard.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
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 text-base font-semibold sm:text-lg ${valueClassName}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
39
src/constants/partner.ts
Normal file
39
src/constants/partner.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const PARTNER_STATS = {
|
||||
/** Chart visual configuration */
|
||||
CHART: {
|
||||
HEIGHT: 200,
|
||||
MARGIN: { top: 5, right: 10, left: 0, bottom: 5 },
|
||||
},
|
||||
/** Kopeks-to-currency divisor */
|
||||
KOPEKS_DIVISOR: 100,
|
||||
/** Copy feedback duration in ms */
|
||||
COPY_FEEDBACK_MS: 2000,
|
||||
/** Gradient stop offsets */
|
||||
GRADIENT: {
|
||||
START_OFFSET: '5%',
|
||||
END_OFFSET: '95%',
|
||||
START_OPACITY: 0.3,
|
||||
END_OPACITY: 0,
|
||||
},
|
||||
/** Axis tick styling */
|
||||
AXIS: {
|
||||
TICK_FONT_SIZE: 11,
|
||||
EARNINGS_WIDTH: 45,
|
||||
REFERRALS_WIDTH: 30,
|
||||
},
|
||||
/** Tooltip styling */
|
||||
TOOLTIP: {
|
||||
BORDER_RADIUS: '8px',
|
||||
FONT_SIZE: '12px',
|
||||
},
|
||||
/** Area stroke width */
|
||||
STROKE_WIDTH: 2,
|
||||
/** Grid dash array */
|
||||
GRID_DASH: '3 3',
|
||||
/** 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: 60_000,
|
||||
} as const;
|
||||
63
src/hooks/useChartColors.ts
Normal file
63
src/hooks/useChartColors.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
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.
|
||||
*
|
||||
* No memoization — getComputedStyle is cheap and themes can change at any time.
|
||||
*/
|
||||
export function useChartColors(): ChartColors {
|
||||
return {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -750,6 +750,28 @@
|
||||
"tariff": "Bonus tariff",
|
||||
"tariffDesc": "Free tariff plan"
|
||||
},
|
||||
"stats": {
|
||||
"registrations": "Registrations",
|
||||
"referrals": "Referrals",
|
||||
"earnings": "Earnings",
|
||||
"conversionRate": "Conversion",
|
||||
"today": "Today",
|
||||
"week": "Week",
|
||||
"month": "Month",
|
||||
"showDetails": "Details",
|
||||
"hideDetails": "Collapse",
|
||||
"vsLastWeek": "vs last week",
|
||||
"topReferrals": "Top Referrals",
|
||||
"noData": "No data",
|
||||
"dailyChart": "30-day trends",
|
||||
"periodComparison": "Period comparison",
|
||||
"noReferrals": "No referrals yet",
|
||||
"paid": "Paid",
|
||||
"pending": "Pending",
|
||||
"active": "Active",
|
||||
"referralsCount": "Referrals",
|
||||
"earningsAmount": "Earned"
|
||||
},
|
||||
"fields": {
|
||||
"companyName": "Company Name",
|
||||
"companyNamePlaceholder": "Your company or brand name",
|
||||
@@ -1991,7 +2013,17 @@
|
||||
"users": "Users",
|
||||
"noUsers": "No registered users",
|
||||
"paid": "Paid",
|
||||
"hasSub": "Has sub"
|
||||
"hasSub": "Has sub",
|
||||
"dailyChart": "30-day trends",
|
||||
"chartRegistrations": "Registrations",
|
||||
"chartRevenue": "Revenue",
|
||||
"totalDeposits": "Total Deposits",
|
||||
"totalSpending": "Subscription Spending",
|
||||
"periodComparison": "Period comparison",
|
||||
"vsLastWeek": "vs last week",
|
||||
"topRegistrations": "Top registrations",
|
||||
"noRegistrations": "No registrations yet",
|
||||
"chartLoading": "Loading chart data..."
|
||||
},
|
||||
"users": {
|
||||
"title": "Campaign Users",
|
||||
|
||||
@@ -584,6 +584,28 @@
|
||||
"tariff": "تعرفه جایزه",
|
||||
"tariffDesc": "طرح تعرفه رایگان"
|
||||
},
|
||||
"stats": {
|
||||
"registrations": "ثبتنام",
|
||||
"referrals": "معرفیشدهها",
|
||||
"earnings": "درآمد",
|
||||
"conversionRate": "نرخ تبدیل",
|
||||
"today": "امروز",
|
||||
"week": "هفته",
|
||||
"month": "ماه",
|
||||
"showDetails": "جزئیات",
|
||||
"hideDetails": "بستن",
|
||||
"vsLastWeek": "مقایسه با هفته قبل",
|
||||
"topReferrals": "برترین معرفیشدهها",
|
||||
"noData": "دادهای نیست",
|
||||
"dailyChart": "روند ۳۰ روزه",
|
||||
"periodComparison": "مقایسه دورهها",
|
||||
"noReferrals": "هنوز معرفیشدهای نیست",
|
||||
"paid": "پرداخت شده",
|
||||
"pending": "در انتظار",
|
||||
"active": "فعال",
|
||||
"referralsCount": "تعداد معرفی",
|
||||
"earningsAmount": "کسب شده"
|
||||
},
|
||||
"fields": {
|
||||
"companyName": "نام شرکت",
|
||||
"companyNamePlaceholder": "نام شرکت یا برند شما",
|
||||
@@ -1670,7 +1692,17 @@
|
||||
"users": "کاربران",
|
||||
"noUsers": "کاربر ثبتنام شدهای وجود ندارد",
|
||||
"paid": "پرداخت کرده",
|
||||
"hasSub": "دارای اشتراک"
|
||||
"hasSub": "دارای اشتراک",
|
||||
"dailyChart": "روند ۳۰ روزه",
|
||||
"chartRegistrations": "ثبتنامها",
|
||||
"chartRevenue": "درآمد",
|
||||
"totalDeposits": "کل واریزیها",
|
||||
"totalSpending": "هزینه اشتراک",
|
||||
"periodComparison": "مقایسه دورهها",
|
||||
"vsLastWeek": "نسبت به هفته قبل",
|
||||
"topRegistrations": "بالاترین ثبتنامها",
|
||||
"noRegistrations": "هنوز ثبتنامی وجود ندارد",
|
||||
"chartLoading": "در حال بارگذاری..."
|
||||
},
|
||||
"users": {
|
||||
"title": "کاربران کمپین",
|
||||
|
||||
@@ -778,6 +778,28 @@
|
||||
"tariff": "Бонусный тариф",
|
||||
"tariffDesc": "Бесплатный тарифный план"
|
||||
},
|
||||
"stats": {
|
||||
"registrations": "Регистрации",
|
||||
"referrals": "Рефералы",
|
||||
"earnings": "Заработок",
|
||||
"conversionRate": "Конверсия",
|
||||
"today": "Сегодня",
|
||||
"week": "Неделя",
|
||||
"month": "Месяц",
|
||||
"showDetails": "Подробнее",
|
||||
"hideDetails": "Свернуть",
|
||||
"vsLastWeek": "vs прошлая неделя",
|
||||
"topReferrals": "Топ рефералы",
|
||||
"noData": "Нет данных",
|
||||
"dailyChart": "Динамика за 30 дней",
|
||||
"periodComparison": "Сравнение периодов",
|
||||
"noReferrals": "Пока нет рефералов",
|
||||
"paid": "Оплатил",
|
||||
"pending": "Ожидание",
|
||||
"active": "Активен",
|
||||
"referralsCount": "Рефералов",
|
||||
"earningsAmount": "Заработано"
|
||||
},
|
||||
"fields": {
|
||||
"companyName": "Название компании",
|
||||
"companyNamePlaceholder": "Название вашей компании или бренда",
|
||||
@@ -2512,7 +2534,17 @@
|
||||
"users": "Пользователи",
|
||||
"noUsers": "Нет зарегистрированных пользователей",
|
||||
"paid": "Платил",
|
||||
"hasSub": "Подписка"
|
||||
"hasSub": "Подписка",
|
||||
"dailyChart": "Динамика за 30 дней",
|
||||
"chartRegistrations": "Регистрации",
|
||||
"chartRevenue": "Доход",
|
||||
"totalDeposits": "Всего пополнений",
|
||||
"totalSpending": "Расход на подписки",
|
||||
"periodComparison": "Сравнение периодов",
|
||||
"vsLastWeek": "vs прошлая неделя",
|
||||
"topRegistrations": "Топ регистрации",
|
||||
"noRegistrations": "Пока нет регистраций",
|
||||
"chartLoading": "Загрузка данных..."
|
||||
},
|
||||
"users": {
|
||||
"title": "Пользователи кампании",
|
||||
|
||||
@@ -584,6 +584,28 @@
|
||||
"tariff": "奖励套餐",
|
||||
"tariffDesc": "免费套餐方案"
|
||||
},
|
||||
"stats": {
|
||||
"registrations": "注册",
|
||||
"referrals": "推荐",
|
||||
"earnings": "收入",
|
||||
"conversionRate": "转化率",
|
||||
"today": "今天",
|
||||
"week": "本周",
|
||||
"month": "本月",
|
||||
"showDetails": "详情",
|
||||
"hideDetails": "收起",
|
||||
"vsLastWeek": "对比上周",
|
||||
"topReferrals": "热门推荐",
|
||||
"noData": "暂无数据",
|
||||
"dailyChart": "30天趋势",
|
||||
"periodComparison": "周期对比",
|
||||
"noReferrals": "暂无推荐",
|
||||
"paid": "已付款",
|
||||
"pending": "待处理",
|
||||
"active": "活跃",
|
||||
"referralsCount": "推荐数",
|
||||
"earningsAmount": "收入"
|
||||
},
|
||||
"fields": {
|
||||
"companyName": "公司名称",
|
||||
"companyNamePlaceholder": "您的公司或品牌名称",
|
||||
@@ -1669,7 +1691,17 @@
|
||||
"users": "用户",
|
||||
"noUsers": "没有注册用户",
|
||||
"paid": "已付费",
|
||||
"hasSub": "有订阅"
|
||||
"hasSub": "有订阅",
|
||||
"dailyChart": "30天趋势",
|
||||
"chartRegistrations": "注册",
|
||||
"chartRevenue": "收入",
|
||||
"totalDeposits": "总充值",
|
||||
"totalSpending": "订阅支出",
|
||||
"periodComparison": "期间比较",
|
||||
"vsLastWeek": "与上周对比",
|
||||
"topRegistrations": "热门注册",
|
||||
"noRegistrations": "暂无注册",
|
||||
"chartLoading": "加载图表数据..."
|
||||
},
|
||||
"users": {
|
||||
"title": "活动用户",
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import { campaignsApi, CampaignBonusType } from '../api/campaigns';
|
||||
import type { AdminCampaignChartData } from '../api/campaigns';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
import { DailyChart, PeriodComparison, StatCard } from '../components/stats';
|
||||
import { PARTNER_STATS } from '../constants/partner';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { copyToClipboard } from '../utils/clipboard';
|
||||
import { useHaptic } from '../platform';
|
||||
|
||||
// Icons
|
||||
const CopyIcon = () => (
|
||||
@@ -74,31 +79,12 @@ const bonusTypeConfig: Record<
|
||||
},
|
||||
};
|
||||
|
||||
// Helper functions
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
|
||||
const formatRubles = (kopeks: number): string => {
|
||||
const rubles = kopeks / 100;
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return `${rubles.toLocaleString(locale)} ₽`;
|
||||
};
|
||||
|
||||
const formatDate = (date: string | null): string => {
|
||||
if (!date) return '-';
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return new Date(date).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export default function AdminCampaignStats() {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const haptic = useHaptic();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const [copiedBot, setCopiedBot] = useState(false);
|
||||
const [copiedWeb, setCopiedWeb] = useState(false);
|
||||
const [showUsers, setShowUsers] = useState(false);
|
||||
@@ -130,31 +116,54 @@ export default function AdminCampaignStats() {
|
||||
enabled: !!id && showUsers,
|
||||
});
|
||||
|
||||
const copyBotLink = async () => {
|
||||
if (stats?.deep_link) {
|
||||
// Fetch chart data
|
||||
const { data: chartData, isLoading: chartLoading } = useQuery<AdminCampaignChartData>({
|
||||
queryKey: ['campaign-chart-data', id],
|
||||
queryFn: () => campaignsApi.getChartData(Number(id)),
|
||||
enabled: !!id,
|
||||
staleTime: PARTNER_STATS.STATS_STALE_TIME,
|
||||
});
|
||||
|
||||
const handleCopy = useCallback(
|
||||
async (url: string, type: 'bot' | 'web') => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(stats.deep_link);
|
||||
await copyToClipboard(url);
|
||||
haptic.notification('success');
|
||||
if (type === 'bot') {
|
||||
setCopiedBot(true);
|
||||
clearTimeout(copyBotTimer.current);
|
||||
copyBotTimer.current = setTimeout(() => setCopiedBot(false), 2000);
|
||||
} catch {
|
||||
// Clipboard not available
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyWebLink = async () => {
|
||||
if (stats?.web_link) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(stats.web_link);
|
||||
copyBotTimer.current = setTimeout(
|
||||
() => setCopiedBot(false),
|
||||
PARTNER_STATS.COPY_FEEDBACK_MS,
|
||||
);
|
||||
} else {
|
||||
setCopiedWeb(true);
|
||||
clearTimeout(copyWebTimer.current);
|
||||
copyWebTimer.current = setTimeout(() => setCopiedWeb(false), 2000);
|
||||
copyWebTimer.current = setTimeout(
|
||||
() => setCopiedWeb(false),
|
||||
PARTNER_STATS.COPY_FEEDBACK_MS,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Clipboard not available
|
||||
haptic.notification('error');
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[haptic],
|
||||
);
|
||||
|
||||
const formatDate = useCallback(
|
||||
(date: string | null): string => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString(i18n.language, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
},
|
||||
[i18n.language],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -195,8 +204,8 @@ export default function AdminCampaignStats() {
|
||||
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
|
||||
<ChartIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{stats.name}</h1>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-semibold text-dark-100">{stats.name}</h1>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${bonusTypeConfig[stats.bonus_type].bgColor} ${bonusTypeConfig[stats.bonus_type].color}`}
|
||||
@@ -232,8 +241,8 @@ export default function AdminCampaignStats() {
|
||||
<span className="truncate text-sm text-dark-300">{stats.deep_link}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyBotLink}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-1.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
onClick={() => handleCopy(stats.deep_link!, 'bot')}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-2 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CopyIcon />
|
||||
<span className="text-sm">
|
||||
@@ -256,8 +265,8 @@ export default function AdminCampaignStats() {
|
||||
<span className="truncate text-sm text-dark-300">{stats.web_link}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyWebLink}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-1.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
onClick={() => handleCopy(stats.web_link!, 'web')}
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-700 px-3 py-2 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CopyIcon />
|
||||
<span className="text-sm">
|
||||
@@ -275,21 +284,25 @@ export default function AdminCampaignStats() {
|
||||
{/* Main Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-dark-100">{stats.registrations}</div>
|
||||
<div className="text-xl font-bold text-dark-100 sm:text-2xl">{stats.registrations}</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.registrations')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
{formatRubles(stats.total_revenue_kopeks)}
|
||||
<div className="truncate text-xl font-bold text-success-400 sm:text-2xl">
|
||||
{formatWithCurrency(stats.total_revenue_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.revenue')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">{stats.paid_users_count}</div>
|
||||
<div className="text-xl font-bold text-accent-400 sm:text-2xl">
|
||||
{stats.paid_users_count}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.paidUsers')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4 text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">{stats.conversion_rate}%</div>
|
||||
<div className="text-xl font-bold text-accent-400 sm:text-2xl">
|
||||
{stats.conversion_rate}%
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.conversion')}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,7 +319,7 @@ export default function AdminCampaignStats() {
|
||||
</div>
|
||||
{stats.bonus_type === 'balance' && (
|
||||
<div className="text-lg font-medium text-success-400">
|
||||
{formatRubles(stats.balance_issued_kopeks)}
|
||||
{formatWithCurrency(stats.balance_issued_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
</div>
|
||||
)}
|
||||
{stats.bonus_type === 'subscription' && (
|
||||
@@ -330,7 +343,9 @@ export default function AdminCampaignStats() {
|
||||
{t('admin.campaigns.stats.avgRevenuePerUser')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{formatRubles(stats.avg_revenue_per_user_kopeks)}
|
||||
{formatWithCurrency(
|
||||
stats.avg_revenue_per_user_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
@@ -338,7 +353,7 @@ export default function AdminCampaignStats() {
|
||||
{t('admin.campaigns.stats.avgFirstPayment')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{formatRubles(stats.avg_first_payment_kopeks)}
|
||||
{formatWithCurrency(stats.avg_first_payment_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
@@ -371,6 +386,94 @@ export default function AdminCampaignStats() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics Charts */}
|
||||
<div className="space-y-4">
|
||||
{chartLoading ? (
|
||||
<div className="space-y-3">
|
||||
<div className="h-52 animate-pulse rounded-xl bg-dark-800/30" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="h-24 animate-pulse rounded-xl bg-dark-800/30" />
|
||||
<div className="h-24 animate-pulse rounded-xl bg-dark-800/30" />
|
||||
</div>
|
||||
</div>
|
||||
) : chartData ? (
|
||||
<>
|
||||
{/* Deposits vs Spending */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<StatCard
|
||||
label={t('admin.campaigns.stats.totalDeposits')}
|
||||
value={formatWithCurrency(
|
||||
chartData.total_deposits_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
)}
|
||||
valueClassName="text-success-400"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('admin.campaigns.stats.totalSpending')}
|
||||
value={formatWithCurrency(
|
||||
chartData.total_spending_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
)}
|
||||
valueClassName="text-accent-400"
|
||||
/>
|
||||
</div>
|
||||
<DailyChart
|
||||
data={chartData.daily_stats}
|
||||
chartId={`admin-${id}`}
|
||||
title={t('admin.campaigns.stats.dailyChart')}
|
||||
earningsLabel={t('admin.campaigns.stats.chartRevenue')}
|
||||
countLabel={t('admin.campaigns.stats.chartRegistrations')}
|
||||
/>
|
||||
<PeriodComparison
|
||||
data={chartData.period_comparison}
|
||||
title={t('admin.campaigns.stats.periodComparison')}
|
||||
countLabel={t('admin.campaigns.stats.chartRegistrations')}
|
||||
earningsLabel={t('admin.campaigns.stats.chartRevenue')}
|
||||
comparisonLabel={t('admin.campaigns.stats.vsLastWeek')}
|
||||
/>
|
||||
{/* Top Registrations */}
|
||||
{chartData.top_registrations.length > 0 && (
|
||||
<div className="bento-card">
|
||||
<h4 className="mb-3 text-sm font-semibold text-dark-200">
|
||||
{t('admin.campaigns.stats.topRegistrations')}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{chartData.top_registrations.map((reg) => (
|
||||
<Link
|
||||
key={reg.id}
|
||||
to={`/admin/users/${reg.id}`}
|
||||
className="flex items-center justify-between rounded-xl border border-dark-700/30 bg-dark-800/30 p-3 transition-colors hover:bg-dark-700/50"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 truncate text-sm font-medium text-dark-100">
|
||||
{reg.full_name}
|
||||
</span>
|
||||
{reg.is_active && (
|
||||
<span className="badge-success">
|
||||
{t('admin.campaigns.stats.active')}
|
||||
</span>
|
||||
)}
|
||||
{reg.has_paid && !reg.is_active && (
|
||||
<span className="badge-info">{t('admin.campaigns.stats.paid')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-dark-500">
|
||||
{new Date(reg.created_at).toLocaleDateString(i18n.language)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-success-400">
|
||||
{formatWithCurrency(
|
||||
reg.total_earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR,
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Users Section */}
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800">
|
||||
<button
|
||||
|
||||
@@ -6,6 +6,7 @@ import { referralApi } from '../api/referral';
|
||||
import { brandingApi } from '../api/branding';
|
||||
import { partnerApi } from '../api/partners';
|
||||
import { withdrawalApi } from '../api/withdrawals';
|
||||
import { CampaignCard } from '../components/partner/CampaignCard';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
|
||||
const LinkIcon = () => (
|
||||
@@ -93,7 +94,6 @@ export default function Referral() {
|
||||
const { formatAmount, currencySymbol, formatPositive, formatWithCurrency } = useCurrency();
|
||||
const queryClient = useQueryClient();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||||
|
||||
const { data: info, isLoading } = useQuery({
|
||||
queryKey: ['referral-info'],
|
||||
@@ -535,99 +535,9 @@ export default function Referral() {
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{partnerStatus.campaigns.map((campaign) => {
|
||||
const copyLink = (url: string, key: string) => {
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopiedLink(key);
|
||||
setTimeout(() => setCopiedLink(null), 2000);
|
||||
};
|
||||
|
||||
const botKey = `${campaign.id}-bot`;
|
||||
const webKey = `${campaign.id}-web`;
|
||||
|
||||
return (
|
||||
<div key={campaign.id} className="bento-card space-y-4">
|
||||
{/* Campaign header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-dark-100">{campaign.name}</h3>
|
||||
</div>
|
||||
|
||||
{/* Bonus info */}
|
||||
{campaign.bonus_type !== 'none' && (
|
||||
<div className="rounded-lg bg-success-500/10 p-3">
|
||||
<div className="mb-1 text-xs font-medium text-success-500">
|
||||
{t('referral.partner.campaignBonus.title')}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-success-400">
|
||||
{campaign.bonus_type === 'balance' &&
|
||||
t('referral.partner.campaignBonus.balanceDesc', {
|
||||
amount: formatWithCurrency(campaign.balance_bonus_kopeks / 100, 0),
|
||||
})}
|
||||
{campaign.bonus_type === 'subscription' &&
|
||||
t('referral.partner.campaignBonus.subscriptionDesc', {
|
||||
days: campaign.subscription_duration_days ?? 0,
|
||||
...(campaign.subscription_traffic_gb
|
||||
? { traffic: campaign.subscription_traffic_gb }
|
||||
: {}),
|
||||
})}
|
||||
{campaign.bonus_type === 'tariff' &&
|
||||
t('referral.partner.campaignBonus.tariffDesc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bot link */}
|
||||
{campaign.deep_link && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||
{t('referral.partner.campaignLinks.bot')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={campaign.deep_link}
|
||||
className="input flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyLink(campaign.deep_link!, botKey)}
|
||||
className={`btn-primary shrink-0 px-3 py-2 ${
|
||||
copiedLink === botKey ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
}`}
|
||||
>
|
||||
{copiedLink === botKey ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Web link */}
|
||||
{campaign.web_link && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-dark-500">
|
||||
{t('referral.partner.campaignLinks.web')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={campaign.web_link}
|
||||
className="input flex-1 text-xs"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyLink(campaign.web_link!, webKey)}
|
||||
className={`btn-primary shrink-0 px-3 py-2 ${
|
||||
copiedLink === webKey ? 'bg-success-500 hover:bg-success-500' : ''
|
||||
}`}
|
||||
>
|
||||
{copiedLink === webKey ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{partnerStatus.campaigns.map((campaign) => (
|
||||
<CampaignCard key={campaign.id} campaign={campaign} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user