From c7d05c4809166341a1702566a343946fe9126797 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 2 Mar 2026 06:10:20 +0300 Subject: [PATCH] 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 --- package-lock.json | 384 +++++++++++++++++- package.json | 1 + src/api/campaigns.ts | 49 +++ src/api/partners.ts | 62 +++ src/components/partner/CampaignCard.tsx | 197 +++++++++ .../partner/CampaignDetailStats.tsx | 100 +++++ src/components/partner/TopReferrals.tsx | 70 ++++ src/components/stats/DailyChart.tsx | 144 +++++++ src/components/stats/PeriodComparison.tsx | 87 ++++ src/components/stats/StatCard.tsx | 27 ++ src/components/stats/index.ts | 9 + src/components/stats/types.ts | 24 ++ src/constants/partner.ts | 39 ++ src/hooks/useChartColors.ts | 63 +++ src/locales/en.json | 34 +- src/locales/fa.json | 34 +- src/locales/ru.json | 34 +- src/locales/zh.json | 34 +- src/pages/AdminCampaignStats.tsx | 221 +++++++--- src/pages/Referral.tsx | 98 +---- src/utils/clipboard.ts | 24 ++ 21 files changed, 1577 insertions(+), 158 deletions(-) create mode 100644 src/components/partner/CampaignCard.tsx create mode 100644 src/components/partner/CampaignDetailStats.tsx create mode 100644 src/components/partner/TopReferrals.tsx create mode 100644 src/components/stats/DailyChart.tsx create mode 100644 src/components/stats/PeriodComparison.tsx create mode 100644 src/components/stats/StatCard.tsx create mode 100644 src/components/stats/index.ts create mode 100644 src/components/stats/types.ts create mode 100644 src/constants/partner.ts create mode 100644 src/hooks/useChartColors.ts create mode 100644 src/utils/clipboard.ts diff --git a/package-lock.json b/package-lock.json index ad8a110..ecf11fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 24e2a15..f472a6c 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/api/campaigns.ts b/src/api/campaigns.ts index 629c226..4d1828b 100644 --- a/src/api/campaigns.ts +++ b/src/api/campaigns.ts @@ -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 => { + const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/chart-data`); + return response.data; + }, }; diff --git a/src/api/partners.ts b/src/api/partners.ts index d91634b..74153ac 100644 --- a/src/api/partners.ts +++ b/src/api/partners.ts @@ -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 => { + const response = await apiClient.get( + `/cabinet/referral/partner/campaigns/${campaignId}/stats`, + ); + return response.data; + }, + // Admin endpoints getStats: async (): Promise => { const response = await apiClient.get('/cabinet/admin/partners/stats'); diff --git a/src/components/partner/CampaignCard.tsx b/src/components/partner/CampaignCard.tsx new file mode 100644 index 0000000..25c0882 --- /dev/null +++ b/src/components/partner/CampaignCard.tsx @@ -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 = () => ( + + + +); + +const CheckIcon = () => ( + + + +); + +const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( + + + +); + +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(null); + const copyTimerRef = useRef>(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 ( +
+ {/* Campaign header */} +
+

{campaign.name}

+ +
+ + {/* Basic stats -- always visible */} +
+ + + +
+ + {/* Bonus info */} + {campaign.bonus_type !== 'none' && ( +
+
+ {t('referral.partner.campaignBonus.title')} +
+
+ {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')} +
+
+ )} + + {/* Bot link */} + {campaign.deep_link && ( +
+
+ {t('referral.partner.campaignLinks.bot')} +
+
+ + +
+
+ )} + + {/* Web link */} + {campaign.web_link && ( +
+
+ {t('referral.partner.campaignLinks.web')} +
+
+ + +
+
+ )} + + {/* Expanded detail stats */} +
+ {expanded && } +
+
+ ); +} diff --git a/src/components/partner/CampaignDetailStats.tsx b/src/components/partner/CampaignDetailStats.tsx new file mode 100644 index 0000000..9f1fd8a --- /dev/null +++ b/src/components/partner/CampaignDetailStats.tsx @@ -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 ( +
+ {/* Skeleton loader */} +
+ {Array.from({ length: PARTNER_STATS.SKELETON_COUNT }).map((_, i) => ( +
+ ))} +
+
+
+ ); + } + + if (isError || !data) { + return ( +
+
{t('referral.partner.stats.noData')}
+ +
+ ); + } + + return ( +
+ {/* Period earnings */} +
+ + + +
+ + {/* Conversion rate */} +
+
+ + {t('referral.partner.stats.conversionRate')} + + {data.conversion_rate}% +
+
+
+
+
+ + {/* Daily chart */} + + + {/* Period comparison */} + + + {/* Top referrals */} + +
+ ); +} diff --git a/src/components/partner/TopReferrals.tsx b/src/components/partner/TopReferrals.tsx new file mode 100644 index 0000000..e3450a3 --- /dev/null +++ b/src/components/partner/TopReferrals.tsx @@ -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 {t('referral.partner.stats.active')}; + } + if (hasPaid) { + return {t('referral.partner.stats.paid')}; + } + return {t('referral.partner.stats.pending')}; +} + +export function TopReferrals({ referrals }: TopReferralsProps) { + const { t, i18n } = useTranslation(); + const { formatWithCurrency } = useCurrency(); + + if (referrals.length === 0) { + return ( +
+
{t('referral.partner.stats.noReferrals')}
+
+ ); + } + + return ( +
+

+ {t('referral.partner.stats.topReferrals')} +

+
+ {referrals.map((ref) => ( +
+
+
+ + {ref.full_name} + + +
+
+ {new Date(ref.created_at).toLocaleDateString(i18n.language)} +
+
+
+ {formatWithCurrency(ref.total_earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/stats/DailyChart.tsx b/src/components/stats/DailyChart.tsx new file mode 100644 index 0000000..ed6ef48 --- /dev/null +++ b/src/components/stats/DailyChart.tsx @@ -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 ( +
+

{resolvedTitle}

+ + + + + + + + + + + + + + + + + { + const displayValue = value ?? 0; + if (name === 'earnings_display') { + return [formatWithCurrency(displayValue), resolvedEarningsLabel]; + } + return [displayValue, resolvedCountLabel]; + }} + /> + + + + +
+ ); +} diff --git a/src/components/stats/PeriodComparison.tsx b/src/components/stats/PeriodComparison.tsx new file mode 100644 index 0000000..7e03772 --- /dev/null +++ b/src/components/stats/PeriodComparison.tsx @@ -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 ( + + {style.arrow} {Math.abs(percent)}% + + ); +} + +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 ( +
+

{resolvedTitle}

+
+ {/* Count comparison */} +
+
{resolvedCountLabel}
+
+ + {data.current.referrals_count} + + +
+
{resolvedComparisonLabel}
+
+ + {/* Earnings comparison */} +
+
{resolvedEarningsLabel}
+
+ + {formatWithCurrency(data.current.earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)} + + +
+
{resolvedComparisonLabel}
+
+
+
+ ); +} diff --git a/src/components/stats/StatCard.tsx b/src/components/stats/StatCard.tsx new file mode 100644 index 0000000..55ead22 --- /dev/null +++ b/src/components/stats/StatCard.tsx @@ -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 ( +
+
+ {icon} + {label} +
+
{value}
+
+ ); +} diff --git a/src/components/stats/index.ts b/src/components/stats/index.ts new file mode 100644 index 0000000..37bac3f --- /dev/null +++ b/src/components/stats/index.ts @@ -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'; diff --git a/src/components/stats/types.ts b/src/components/stats/types.ts new file mode 100644 index 0000000..4b56043 --- /dev/null +++ b/src/components/stats/types.ts @@ -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; +} diff --git a/src/constants/partner.ts b/src/constants/partner.ts new file mode 100644 index 0000000..282d1e2 --- /dev/null +++ b/src/constants/partner.ts @@ -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; diff --git a/src/hooks/useChartColors.ts b/src/hooks/useChartColors.ts new file mode 100644 index 0000000..3f1c8ec --- /dev/null +++ b/src/hooks/useChartColors.ts @@ -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 = { + 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), + }; +} diff --git a/src/locales/en.json b/src/locales/en.json index e7d818b..2ea4903 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 08b1833..997a409 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -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": "کاربران کمپین", diff --git a/src/locales/ru.json b/src/locales/ru.json index b4cae25..372995b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -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": "Пользователи кампании", diff --git a/src/locales/zh.json b/src/locales/zh.json index 122b6ae..d3ea2f3 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -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": "活动用户", diff --git a/src/pages/AdminCampaignStats.tsx b/src/pages/AdminCampaignStats.tsx index c723c7a..8209496 100644 --- a/src/pages/AdminCampaignStats.tsx +++ b/src/pages/AdminCampaignStats.tsx @@ -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 = { 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) { - try { - await navigator.clipboard.writeText(stats.deep_link); - setCopiedBot(true); - clearTimeout(copyBotTimer.current); - copyBotTimer.current = setTimeout(() => setCopiedBot(false), 2000); - } catch { - // Clipboard not available - } - } - }; + // Fetch chart data + const { data: chartData, isLoading: chartLoading } = useQuery({ + queryKey: ['campaign-chart-data', id], + queryFn: () => campaignsApi.getChartData(Number(id)), + enabled: !!id, + staleTime: PARTNER_STATS.STATS_STALE_TIME, + }); - const copyWebLink = async () => { - if (stats?.web_link) { + const handleCopy = useCallback( + async (url: string, type: 'bot' | 'web') => { try { - await navigator.clipboard.writeText(stats.web_link); - setCopiedWeb(true); - clearTimeout(copyWebTimer.current); - copyWebTimer.current = setTimeout(() => setCopiedWeb(false), 2000); + await copyToClipboard(url); + haptic.notification('success'); + if (type === 'bot') { + setCopiedBot(true); + clearTimeout(copyBotTimer.current); + copyBotTimer.current = setTimeout( + () => setCopiedBot(false), + PARTNER_STATS.COPY_FEEDBACK_MS, + ); + } else { + setCopiedWeb(true); + clearTimeout(copyWebTimer.current); + 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() {
-
-

{stats.name}

+
+

{stats.name}

{stats.deep_link}
- {formatRubles(stats.avg_revenue_per_user_kopeks)} + {formatWithCurrency( + stats.avg_revenue_per_user_kopeks / PARTNER_STATS.KOPEKS_DIVISOR, + )}
@@ -338,7 +353,7 @@ export default function AdminCampaignStats() { {t('admin.campaigns.stats.avgFirstPayment')}
- {formatRubles(stats.avg_first_payment_kopeks)} + {formatWithCurrency(stats.avg_first_payment_kopeks / PARTNER_STATS.KOPEKS_DIVISOR)}
@@ -371,6 +386,94 @@ export default function AdminCampaignStats() {
+ {/* Analytics Charts */} +
+ {chartLoading ? ( +
+
+
+
+
+
+
+ ) : chartData ? ( + <> + {/* Deposits vs Spending */} +
+ + +
+ + + {/* Top Registrations */} + {chartData.top_registrations.length > 0 && ( +
+

+ {t('admin.campaigns.stats.topRegistrations')} +

+
+ {chartData.top_registrations.map((reg) => ( + +
+
+ + {reg.full_name} + + {reg.is_active && ( + + {t('admin.campaigns.stats.active')} + + )} + {reg.has_paid && !reg.is_active && ( + {t('admin.campaigns.stats.paid')} + )} +
+
+ {new Date(reg.created_at).toLocaleDateString(i18n.language)} +
+
+
+ {formatWithCurrency( + reg.total_earnings_kopeks / PARTNER_STATS.KOPEKS_DIVISOR, + )} +
+ + ))} +
+
+ )} + + ) : null} +
+ {/* Users Section */}
- {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 ( -
- {/* Campaign header */} -
-

{campaign.name}

-
- - {/* Bonus info */} - {campaign.bonus_type !== 'none' && ( -
-
- {t('referral.partner.campaignBonus.title')} -
-
- {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')} -
-
- )} - - {/* Bot link */} - {campaign.deep_link && ( -
-
- {t('referral.partner.campaignLinks.bot')} -
-
- - -
-
- )} - - {/* Web link */} - {campaign.web_link && ( -
-
- {t('referral.partner.campaignLinks.web')} -
-
- - -
-
- )} -
- ); - })} + {partnerStatus.campaigns.map((campaign) => ( + + ))}
)} diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts new file mode 100644 index 0000000..d854d91 --- /dev/null +++ b/src/utils/clipboard.ts @@ -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 { + 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); + } + } +}