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:
Fringg
2026-03-02 06:10:20 +03:00
parent 60f16e64e8
commit c7d05c4809
21 changed files with 1577 additions and 158 deletions

384
package-lock.json generated
View File

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

View File

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

View File

@@ -149,6 +149,49 @@ export interface CampaignsOverview {
total_tariff_issued: number;
}
export interface AdminDailyStatItem {
date: string;
referrals_count: number;
earnings_kopeks: number;
}
export interface AdminPeriodStats {
days: number;
referrals_count: number;
earnings_kopeks: number;
}
export interface AdminPeriodChange {
absolute: number;
percent: number;
trend: 'up' | 'down' | 'stable';
}
export interface AdminPeriodComparison {
current: AdminPeriodStats;
previous: AdminPeriodStats;
referrals_change: AdminPeriodChange;
earnings_change: AdminPeriodChange;
}
export interface AdminTopRegistrationItem {
id: number;
full_name: string;
created_at: string;
has_paid: boolean;
is_active: boolean;
total_earnings_kopeks: number;
}
export interface AdminCampaignChartData {
campaign_id: number;
total_deposits_kopeks: number;
total_spending_kopeks: number;
daily_stats: AdminDailyStatItem[];
period_comparison: AdminPeriodComparison;
top_registrations: AdminTopRegistrationItem[];
}
export interface ServerSquadInfo {
id: number;
squad_uuid: string;
@@ -258,4 +301,10 @@ export const campaignsApi = {
const response = await apiClient.get('/cabinet/admin/campaigns/available-partners');
return response.data;
},
// Get campaign chart data for analytics
getChartData: async (campaignId: number): Promise<AdminCampaignChartData> => {
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/chart-data`);
return response.data;
},
};

View File

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

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

View File

@@ -0,0 +1,100 @@
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { partnerApi } from '../../api/partners';
import { PARTNER_STATS } from '../../constants/partner';
import { useCurrency } from '../../hooks/useCurrency';
import { DailyChart } from '../stats/DailyChart';
import { PeriodComparison } from '../stats/PeriodComparison';
import { StatCard } from '../stats/StatCard';
import { TopReferrals } from './TopReferrals';
interface CampaignDetailStatsProps {
campaignId: number;
}
export function CampaignDetailStats({ campaignId }: CampaignDetailStatsProps) {
const { t } = useTranslation();
const { formatWithCurrency } = useCurrency();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['partner-campaign-stats', campaignId],
queryFn: () => partnerApi.getCampaignStats(campaignId),
staleTime: PARTNER_STATS.STATS_STALE_TIME,
});
if (isLoading) {
return (
<div className="space-y-3 pt-2">
{/* Skeleton loader */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{Array.from({ length: PARTNER_STATS.SKELETON_COUNT }).map((_, i) => (
<div key={i} className="h-16 animate-pulse rounded-xl bg-dark-800/30" />
))}
</div>
<div className="h-52 animate-pulse rounded-xl bg-dark-800/30" />
</div>
);
}
if (isError || !data) {
return (
<div className="pt-2 text-center">
<div className="text-sm text-error-400">{t('referral.partner.stats.noData')}</div>
<button onClick={() => refetch()} className="btn-secondary mt-2 px-4 py-1 text-xs">
{t('common.retry')}
</button>
</div>
);
}
return (
<div className="space-y-3 pt-2">
{/* Period earnings */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
<StatCard
label={t('referral.partner.stats.today')}
value={formatWithCurrency(data.earnings_today / PARTNER_STATS.KOPEKS_DIVISOR)}
valueClassName="text-success-400"
/>
<StatCard
label={t('referral.partner.stats.week')}
value={formatWithCurrency(data.earnings_week / PARTNER_STATS.KOPEKS_DIVISOR)}
valueClassName="text-success-400"
/>
<StatCard
label={t('referral.partner.stats.month')}
value={formatWithCurrency(data.earnings_month / PARTNER_STATS.KOPEKS_DIVISOR)}
valueClassName="text-success-400"
/>
</div>
{/* Conversion rate */}
<div className="rounded-xl bg-dark-800/30 p-3">
<div className="flex items-center justify-between">
<span className="text-sm text-dark-500">
{t('referral.partner.stats.conversionRate')}
</span>
<span className="text-lg font-semibold text-accent-400">{data.conversion_rate}%</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-dark-700">
<div
className="h-full rounded-full bg-accent-500 transition-all"
style={{
width: `${Math.min(data.conversion_rate, PARTNER_STATS.MAX_CONVERSION_RATE)}%`,
}}
/>
</div>
</div>
{/* Daily chart */}
<DailyChart data={data.daily_stats} chartId={campaignId} />
{/* Period comparison */}
<PeriodComparison data={data.period_comparison} />
{/* Top referrals */}
<TopReferrals referrals={data.top_referrals} />
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useTranslation } from 'react-i18next';
import type { CampaignReferralItem } from '../../api/partners';
import { PARTNER_STATS } from '../../constants/partner';
import { useCurrency } from '../../hooks/useCurrency';
interface TopReferralsProps {
referrals: CampaignReferralItem[];
}
interface StatusBadgeProps {
hasPaid: boolean;
isActive: boolean;
}
function StatusBadge({ hasPaid, isActive }: StatusBadgeProps) {
const { t } = useTranslation();
if (isActive) {
return <span className="badge-success">{t('referral.partner.stats.active')}</span>;
}
if (hasPaid) {
return <span className="badge-info">{t('referral.partner.stats.paid')}</span>;
}
return <span className="badge-neutral">{t('referral.partner.stats.pending')}</span>;
}
export function TopReferrals({ referrals }: TopReferralsProps) {
const { t, i18n } = useTranslation();
const { formatWithCurrency } = useCurrency();
if (referrals.length === 0) {
return (
<div className="bento-card py-6 text-center">
<div className="text-sm text-dark-400">{t('referral.partner.stats.noReferrals')}</div>
</div>
);
}
return (
<div className="bento-card">
<h4 className="mb-3 text-sm font-semibold text-dark-200">
{t('referral.partner.stats.topReferrals')}
</h4>
<div className="space-y-2">
{referrals.map((ref) => (
<div
key={ref.id}
className="flex items-center justify-between rounded-xl border border-dark-700/30 bg-dark-800/30 p-3"
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 truncate text-sm font-medium text-dark-100">
{ref.full_name}
</span>
<StatusBadge hasPaid={ref.has_paid} isActive={ref.is_active} />
</div>
<div className="mt-0.5 text-xs text-dark-500">
{new Date(ref.created_at).toLocaleDateString(i18n.language)}
</div>
</div>
<div className="text-sm font-semibold text-success-400">
{formatWithCurrency(ref.total_earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
</div>
</div>
))}
</div>
</div>
);
}

View File

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

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

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

View File

@@ -0,0 +1,9 @@
export { DailyChart } from './DailyChart';
export { PeriodComparison } from './PeriodComparison';
export { StatCard } from './StatCard';
export type {
DailyStatItem,
PeriodStats,
PeriodChange,
PeriodComparison as PeriodComparisonType,
} from './types';

View File

@@ -0,0 +1,24 @@
export interface DailyStatItem {
date: string;
referrals_count: number;
earnings_kopeks: number;
}
export interface PeriodStats {
days: number;
referrals_count: number;
earnings_kopeks: number;
}
export interface PeriodChange {
absolute: number;
percent: number;
trend: 'up' | 'down' | 'stable';
}
export interface PeriodComparison {
current: PeriodStats;
previous: PeriodStats;
referrals_change: PeriodChange;
earnings_change: PeriodChange;
}

39
src/constants/partner.ts Normal file
View 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;

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

View File

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

View File

@@ -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": "کاربران کمپین",

View File

@@ -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": "Пользователи кампании",

View File

@@ -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": "活动用户",

View File

@@ -1,10 +1,15 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useParams, useNavigate, Link } from 'react-router';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import i18n from '../i18n';
import { campaignsApi, CampaignBonusType } from '../api/campaigns';
import type { AdminCampaignChartData } from '../api/campaigns';
import { AdminBackButton } from '../components/admin';
import { DailyChart, PeriodComparison, StatCard } from '../components/stats';
import { PARTNER_STATS } from '../constants/partner';
import { useCurrency } from '../hooks/useCurrency';
import { copyToClipboard } from '../utils/clipboard';
import { useHaptic } from '../platform';
// Icons
const CopyIcon = () => (
@@ -74,31 +79,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

View File

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

@@ -0,0 +1,24 @@
/**
* Cross-platform clipboard utility with fallback for Telegram WebView.
* navigator.clipboard.writeText() is unavailable in some WebViews
* (Android System WebView, unfocused tabs, insecure contexts).
*/
export async function copyToClipboard(text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
} catch {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.left = '-9999px';
document.body.appendChild(textarea);
try {
textarea.select();
const ok = document.execCommand('copy');
if (!ok) throw new Error('execCommand copy failed');
} finally {
document.body.removeChild(textarea);
}
}
}