diff --git a/package-lock.json b/package-lock.json index f9a049c..d0f02a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,11 +36,13 @@ "i18next": "^25.8.4", "i18next-browser-languagedetector": "^8.2.0", "jsencrypt": "^3.5.4", + "qrcode.react": "^4.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^16.5.4", "react-router": "^7.13.0", "react-twemoji": "^0.7.2", + "recharts": "^3.7.0", "simplex-noise": "^4.0.3", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11" @@ -2244,6 +2246,42 @@ "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "license": "MIT" }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.2", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", @@ -2601,6 +2639,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tanstack/query-core": { "version": "5.90.20", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", @@ -2826,6 +2876,69 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/dompurify": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", @@ -2897,6 +3010,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", @@ -3756,6 +3875,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3774,6 +4014,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3919,6 +4165,16 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -4187,7 +4443,6 @@ "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, "license": "MIT" }, "node_modules/fast-deep-equal": { @@ -4692,6 +4947,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4719,6 +4984,15 @@ "node": ">=0.8.19" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -5673,6 +5947,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -5742,6 +6025,36 @@ } } }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -5883,6 +6196,57 @@ "node": ">=8.10.0" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -6307,6 +6671,12 @@ "node": ">=0.8" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6574,6 +6944,28 @@ } } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 376a6b9..3d0dbdb 100644 --- a/package.json +++ b/package.json @@ -44,11 +44,13 @@ "i18next": "^25.8.4", "i18next-browser-languagedetector": "^8.2.0", "jsencrypt": "^3.5.4", + "qrcode.react": "^4.2.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^16.5.4", "react-router": "^7.13.0", "react-twemoji": "^0.7.2", + "recharts": "^3.7.0", "simplex-noise": "^4.0.3", "tailwind-merge": "^3.4.0", "zustand": "^5.0.11" diff --git a/src/App.tsx b/src/App.tsx index 916a3bf..e03ee71 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import Dashboard from './pages/Dashboard'; // User pages - lazy load const Subscription = lazy(() => import('./pages/Subscription')); +const SubscriptionPurchase = lazy(() => import('./pages/SubscriptionPurchase')); const Balance = lazy(() => import('./pages/Balance')); const Referral = lazy(() => import('./pages/Referral')); const Support = lazy(() => import('./pages/Support')); @@ -35,6 +36,7 @@ const Polls = lazy(() => import('./pages/Polls')); const Info = lazy(() => import('./pages/Info')); const Wheel = lazy(() => import('./pages/Wheel')); const Connection = lazy(() => import('./pages/Connection')); +const ConnectionQR = lazy(() => import('./pages/ConnectionQR')); const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect')); const TopUpAmount = lazy(() => import('./pages/TopUpAmount')); @@ -85,6 +87,7 @@ const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')); const AdminRemnawaveSquadDetail = lazy(() => import('./pages/AdminRemnawaveSquadDetail')); const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates')); const AdminTrafficUsage = lazy(() => import('./pages/AdminTrafficUsage')); +const AdminSalesStats = lazy(() => import('./pages/AdminSalesStats')); const AdminUpdates = lazy(() => import('./pages/AdminUpdates')); const AdminUserDetail = lazy(() => import('./pages/AdminUserDetail')); const AdminBroadcastDetail = lazy(() => import('./pages/AdminBroadcastDetail')); @@ -202,6 +205,16 @@ function App() { } /> + + + + + + } + /> } /> + + + + } + /> } /> + + + + + + } + /> => { + const response = await apiClient.get('/cabinet/admin/stats/sales/summary', { params }); + return response.data; + }, + + getTrials: async (params: SalesStatsParams = {}): Promise => { + const response = await apiClient.get('/cabinet/admin/stats/sales/trials', { params }); + return response.data; + }, + + getSales: async (params: SalesStatsParams = {}): Promise => { + const response = await apiClient.get('/cabinet/admin/stats/sales/subscriptions', { params }); + return response.data; + }, + + getRenewals: async (params: SalesStatsParams = {}): Promise => { + const response = await apiClient.get('/cabinet/admin/stats/sales/renewals', { params }); + return response.data; + }, + + getAddons: async (params: SalesStatsParams = {}): Promise => { + const response = await apiClient.get('/cabinet/admin/stats/sales/addons', { params }); + return response.data; + }, + + getDeposits: async (params: SalesStatsParams = {}): Promise => { + const response = await apiClient.get('/cabinet/admin/stats/sales/deposits', { params }); + return response.data; + }, +}; diff --git a/src/api/auth.ts b/src/api/auth.ts index 8082967..dd010bf 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -174,6 +174,7 @@ export const authApi = { provider: string, code: string, state: string, + deviceId?: string | null, campaignSlug?: string | null, referralCode?: string | null, ): Promise => { @@ -182,6 +183,7 @@ export const authApi = { { code, state, + device_id: deviceId || undefined, campaign_slug: campaignSlug || undefined, referral_code: referralCode || undefined, }, 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 007474f..74153ac 100644 --- a/src/api/partners.ts +++ b/src/api/partners.ts @@ -10,6 +10,7 @@ export interface PartnerApplicationInfo { telegram_channel: string | null; description: string | null; expected_monthly_referrals: number | null; + desired_commission_percent: number | null; admin_comment: string | null; approved_commission_percent: number | null; created_at: string; @@ -26,6 +27,10 @@ export interface PartnerCampaignInfo { subscription_traffic_gb: number | null; deep_link: string | null; web_link: string | null; + // Per-campaign statistics + registrations_count: number; + referrals_count: number; + earnings_kopeks: number; } export interface PartnerStatusResponse { @@ -41,6 +46,58 @@ export interface PartnerApplicationRequest { telegram_channel?: string; description?: string; expected_monthly_referrals?: number; + desired_commission_percent?: number; +} + +// ==================== Campaign detailed stats types ==================== + +export interface DailyStatItem { + date: string; + referrals_count: number; + earnings_kopeks: number; +} + +export interface PeriodStats { + days: number; + referrals_count: number; + earnings_kopeks: number; +} + +export interface PeriodChange { + absolute: number; + percent: number; + trend: 'up' | 'down' | 'stable'; +} + +export interface PeriodComparison { + current: PeriodStats; + previous: PeriodStats; + referrals_change: PeriodChange; + earnings_change: PeriodChange; +} + +export interface CampaignReferralItem { + id: number; + full_name: string; + created_at: string; + has_paid: boolean; + is_active: boolean; + total_earnings_kopeks: number; +} + +export interface PartnerCampaignDetailedStats { + campaign_id: number; + campaign_name: string; + registrations_count: number; + referrals_count: number; + earnings_kopeks: number; + conversion_rate: number; + earnings_today: number; + earnings_week: number; + earnings_month: number; + daily_stats: DailyStatItem[]; + period_comparison: PeriodComparison; + top_referrals: CampaignReferralItem[]; } // ==================== Admin-facing types ==================== @@ -56,6 +113,7 @@ export interface AdminPartnerApplicationItem { telegram_channel: string | null; description: string | null; expected_monthly_referrals: number | null; + desired_commission_percent: number | null; status: string; admin_comment: string | null; approved_commission_percent: number | null; @@ -102,7 +160,15 @@ export interface AdminPartnerDetailResponse { earnings_week: number; earnings_month: number; conversion_to_paid: number; - campaigns: { id: number; name: string; start_parameter: string; is_active: boolean }[]; + campaigns: { + id: number; + name: string; + start_parameter: string; + is_active: boolean; + registrations_count: number; + referrals_count: number; + earnings_kopeks: number; + }[]; created_at: string; } @@ -148,6 +214,13 @@ export const partnerApi = { return response.data; }, + getCampaignStats: async (campaignId: number): Promise => { + 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/api/referral.ts b/src/api/referral.ts index 112c014..e2fecf4 100644 --- a/src/api/referral.ts +++ b/src/api/referral.ts @@ -17,6 +17,7 @@ interface ReferralEarning { reason: string; referral_username: string | null; referral_first_name: string | null; + campaign_name: string | null; created_at: string; } diff --git a/src/api/wheel.ts b/src/api/wheel.ts index a507b77..1c36d94 100644 --- a/src/api/wheel.ts +++ b/src/api/wheel.ts @@ -26,6 +26,7 @@ export interface WheelConfig { can_pay_days: boolean; user_balance_kopeks: number; required_balance_kopeks: number; + has_subscription: boolean; } export interface SpinAvailability { diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx index b0a3ac4..edc9674 100644 --- a/src/components/PromoOffersSection.tsx +++ b/src/components/PromoOffersSection.tsx @@ -156,7 +156,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio }; const handleUseNow = () => { - navigate('/subscription', { state: { scrollToExtend: true } }); + navigate('/subscription/purchase'); }; const handleDeactivateClick = () => { diff --git a/src/components/admin/ThemeTab.tsx b/src/components/admin/ThemeTab.tsx index 3bfad07..1eaaef2 100644 --- a/src/components/admin/ThemeTab.tsx +++ b/src/components/admin/ThemeTab.tsx @@ -149,24 +149,47 @@ export function ThemeTab() { }, }); - // Update a single color in the draft and apply preview instantly + // Throttle applyThemeColors to once per animation frame + const rafRef = useRef(0); + const querySyncRef = useRef(0); + const updateDraftColor = useCallback( (key: keyof ThemeColors, value: string) => { setDraftColors((prev) => { const next = { ...prev, [key]: value }; - applyThemeColors(next); - queryClient.setQueryData(['theme-colors'], next); + + // Throttle CSS variable updates to 1x per frame + cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + applyThemeColors(next); + }); + + // Debounce query cache update (triggers re-renders of other components) + clearTimeout(querySyncRef.current); + querySyncRef.current = window.setTimeout(() => { + queryClient.setQueryData(['theme-colors'], next); + }, 150); + return next; }); }, [queryClient], ); + // Cleanup on unmount + useEffect(() => { + return () => { + cancelAnimationFrame(rafRef.current); + clearTimeout(querySyncRef.current); + }; + }, []); + // Apply a full preset and auto-save to server const applyPreset = useCallback( (colors: Partial) => { setDraftColors((prev) => { const next = { ...prev, ...colors }; + // Preset is a one-shot action — apply immediately (no throttle needed) applyThemeColors(next); queryClient.setQueryData(['theme-colors'], next); // Auto-save preset to server so it persists across navigation diff --git a/src/components/backgrounds/BackgroundRenderer.tsx b/src/components/backgrounds/BackgroundRenderer.tsx index 54f790c..d7e7f93 100644 --- a/src/components/backgrounds/BackgroundRenderer.tsx +++ b/src/components/backgrounds/BackgroundRenderer.tsx @@ -1,4 +1,5 @@ import { Suspense, useMemo } from 'react'; +import { createPortal } from 'react-dom'; import { useQuery } from '@tanstack/react-query'; import { brandingApi } from '@/api/branding'; import type { AnimationConfig, BackgroundType } from '@/components/ui/backgrounds/types'; @@ -6,19 +7,57 @@ import { DEFAULT_ANIMATION_CONFIG } from '@/components/ui/backgrounds/types'; import { backgroundComponents, prefetchBackground } from '@/components/ui/backgrounds/registry'; const ANIMATION_CACHE_KEY = 'cabinet_animation_config'; +const MAX_CONFIG_JSON_LENGTH = 10_000; + +const VALID_TYPES: ReadonlySet = new Set([ + 'aurora', + 'sparkles', + 'vortex', + 'shooting-stars', + 'background-beams', + 'background-beams-collision', + 'gradient-animation', + 'wavy', + 'background-lines', + 'boxes', + 'meteors', + 'grid', + 'dots', + 'spotlight', + 'ripple', + 'none', +]); + +function validateConfig(parsed: unknown): AnimationConfig | null { + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; + const obj = parsed as Record; + if (typeof obj.enabled !== 'boolean') return null; + if (typeof obj.type !== 'string' || !VALID_TYPES.has(obj.type)) return null; + if (typeof obj.opacity !== 'number') return null; + if (typeof obj.blur !== 'number') return null; + if (obj.settings != null && (typeof obj.settings !== 'object' || Array.isArray(obj.settings))) + return null; + return { + enabled: obj.enabled, + type: obj.type as BackgroundType, + opacity: Math.max(0, Math.min(1, obj.opacity)), + blur: Math.max(0, Math.min(50, obj.blur)), + settings: (obj.settings as Record) ?? {}, + reducedOnMobile: typeof obj.reducedOnMobile === 'boolean' ? obj.reducedOnMobile : true, + }; +} function getCachedConfig(): AnimationConfig | null { try { const cached = localStorage.getItem(ANIMATION_CACHE_KEY); - return cached ? JSON.parse(cached) : null; + if (!cached || cached.length > MAX_CONFIG_JSON_LENGTH) return null; + return validateConfig(JSON.parse(cached)); } catch { return null; } } // Prefetch the background JS chunk immediately based on localStorage cache. -// This starts the download before React even renders, so by the time -// Suspense needs the component, the chunk is already loaded. const cachedConfig = getCachedConfig(); if (cachedConfig?.enabled && cachedConfig.type && cachedConfig.type !== 'none') { prefetchBackground(cachedConfig.type); @@ -33,18 +72,27 @@ function setCachedConfig(config: AnimationConfig) { } export function setCachedAnimationConfig(config: AnimationConfig) { - setCachedConfig(config); + const validated = validateConfig(config); + if (validated) setCachedConfig(validated); } function reduceMobileSettings(settings: Record): Record { const reduced = { ...settings }; + // 75% reduction (divide by 4) instead of 50% — much less GPU work if (typeof reduced.particleCount === 'number') - reduced.particleCount = Math.floor((reduced.particleCount as number) / 2); + reduced.particleCount = Math.max(20, Math.floor(reduced.particleCount / 4)); if (typeof reduced.particleDensity === 'number') - reduced.particleDensity = Math.floor((reduced.particleDensity as number) / 2); + reduced.particleDensity = Math.max(50, Math.floor(reduced.particleDensity / 4)); if (typeof reduced.number === 'number') - reduced.number = Math.floor((reduced.number as number) / 2); + reduced.number = Math.max(5, Math.floor(reduced.number / 4)); if ('interactive' in reduced) reduced.interactive = false; + if (typeof reduced.lineCount === 'number') + reduced.lineCount = Math.max(5, Math.floor(reduced.lineCount / 2)); + if (typeof reduced.rippleCount === 'number') + reduced.rippleCount = Math.max(2, Math.floor(reduced.rippleCount / 2)); + if (typeof reduced.count === 'number') reduced.count = Math.max(3, Math.floor(reduced.count / 2)); + if (typeof reduced.rows === 'number') reduced.rows = Math.max(4, Math.floor(reduced.rows * 0.6)); + if (typeof reduced.cols === 'number') reduced.cols = Math.max(4, Math.floor(reduced.cols * 0.6)); return reduced; } @@ -57,7 +105,9 @@ export function BackgroundRenderer() { const { data: config } = useQuery({ queryKey: ['animation-config'], queryFn: async () => { - const result = await brandingApi.getAnimationConfig(); + const raw = await brandingApi.getAnimationConfig(); + // Validate and clamp API response same as localStorage + const result = validateConfig(raw) ?? DEFAULT_ANIMATION_CONFIG; setCachedConfig(result); return result; }, @@ -83,18 +133,24 @@ export function BackgroundRenderer() { ? reduceMobileSettings(effectiveConfig.settings) : effectiveConfig.settings; - return ( + // On mobile, cap blur to 4px max — full blur is extremely GPU-heavy + const effectiveBlur = isMobile ? Math.min(effectiveConfig.blur, 4) : effectiveConfig.blur; + + return createPortal(
0 ? `blur(${effectiveConfig.blur}px)` : undefined, + filter: effectiveBlur > 0 ? `blur(${effectiveBlur}px)` : undefined, contain: 'strict', + backfaceVisibility: 'hidden', }} > -
+ , + document.body, ); } diff --git a/src/components/connection/InstallationGuide.tsx b/src/components/connection/InstallationGuide.tsx index a9905bc..558512a 100644 --- a/src/components/connection/InstallationGuide.tsx +++ b/src/components/connection/InstallationGuide.tsx @@ -43,6 +43,7 @@ interface Props { onOpenDeepLink: (url: string) => void; isTelegramWebApp: boolean; onGoBack: () => void; + onOpenQR?: () => void; } export default function InstallationGuide({ @@ -50,6 +51,7 @@ export default function InstallationGuide({ onOpenDeepLink, isTelegramWebApp, onGoBack, + onOpenQR, }: Props) { const { t, i18n } = useTranslation(); const { isLight } = useTheme(); @@ -206,6 +208,31 @@ export default function InstallationGuide({

{getBaseTranslation('installationGuideHeader', 'subscription.connection.title')}

+ {appConfig.subscriptionUrl && onOpenQR && ( + + )} {availablePlatforms.length > 1 && (
{currentPlatformSvg && ( diff --git a/src/components/dashboard/StatsGrid.tsx b/src/components/dashboard/StatsGrid.tsx index 64d6bb5..6ac21c7 100644 --- a/src/components/dashboard/StatsGrid.tsx +++ b/src/components/dashboard/StatsGrid.tsx @@ -42,7 +42,7 @@ export default function StatsGrid({ refLoading, }: StatsGridProps) { const { t } = useTranslation(); - const { formatAmount, currencySymbol, formatPositive } = useCurrency(); + const { formatAmount, currencySymbol } = useCurrency(); const { isDark } = useTheme(); const g = getGlassColors(isDark); @@ -83,7 +83,7 @@ export default function StatsGrid({ label: t('dashboard.stats.referrals'), value: `${referralCount}`, valueColor: g.text, - subtitle: `${formatPositive(earningsRubles)} ${currencySymbol}`, + subtitle: `+${formatAmount(earningsRubles)} ${currencySymbol}`, subtitleColor: zone.mainHex, to: '/referral', icon: (color: string) => ( diff --git a/src/components/dashboard/SubscriptionCardActive.tsx b/src/components/dashboard/SubscriptionCardActive.tsx index 099faaf..76ba2a7 100644 --- a/src/components/dashboard/SubscriptionCardActive.tsx +++ b/src/components/dashboard/SubscriptionCardActive.tsx @@ -73,9 +73,11 @@ export default function SubscriptionCardActive({ background: g.cardBg, border: subscription.is_trial ? '1px solid rgba(62,219,176,0.15)' - : `1px solid ${g.cardBorder}`, + : isDark + ? `1px solid ${g.cardBorder}` + : `1px solid ${zone.mainHex}25`, padding: '28px 28px 24px', - boxShadow: g.shadow, + boxShadow: isDark ? g.shadow : `0 2px 16px ${zone.mainHex}12, 0 0 0 1px ${zone.mainHex}08`, }} > {/* Trial shimmer border */} @@ -240,19 +242,38 @@ export default function SubscriptionCardActive({
- {/* Device dots */} -