mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Merge pull request #179 from BEDOLAGA-DEV/dev
chore: sync dev → main (v1.9.0)
This commit is contained in:
133
package-lock.json
generated
133
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "cabinet-frontend",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cabinet-frontend",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
@@ -40,6 +40,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-router": "^7.13.0",
|
||||
"react-twemoji": "^0.7.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
@@ -49,6 +50,7 @@
|
||||
"@types/node": "^25.2.1",
|
||||
"@types/react": "^19.2.13",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-twemoji": "^0.4.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
@@ -110,7 +112,6 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -388,7 +389,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -2729,6 +2729,24 @@
|
||||
"deprecated": "This package is not supported anymore. Use @tma.js/types instead",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@twemoji/api": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@twemoji/api/-/api-17.0.2.tgz",
|
||||
"integrity": "sha512-5xIdBcFLpRW4goXNdNNz497TzMHF2a76yElEaqISxVPj+KpJB82KuXHczfjhhTCsPaP1lAhM3qPtvAhMG7e71A==",
|
||||
"license": "MIT AND CC-BY-4.0",
|
||||
"dependencies": {
|
||||
"@twemoji/parser": "17.0.1",
|
||||
"fs-extra": "^8.0.1",
|
||||
"jsonfile": "^5.0.0",
|
||||
"universalify": "^0.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@twemoji/parser": {
|
||||
"version": "17.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@twemoji/parser/-/parser-17.0.1.tgz",
|
||||
"integrity": "sha512-Uiq5sq1Wx91Y6C0DczMlu4Gj4nC/0z8UIhT7S53hi/JATaS1oLM5jXygjot7STtyVoIagfnpsBCWaNy3ayfyFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -2804,7 +2822,6 @@
|
||||
"integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -2815,7 +2832,6 @@
|
||||
"integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2826,11 +2842,20 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-twemoji": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-twemoji/-/react-twemoji-0.4.3.tgz",
|
||||
"integrity": "sha512-VzWkjmO8yJ31JSWgQhe0LCLU3FxcurTOklECm9p8v1xI/WfUBOGbSrqPjQPL46LE4oOqfrYIQ+0LM/8I6A8EDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -2883,7 +2908,6 @@
|
||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
"@typescript-eslint/types": "8.54.0",
|
||||
@@ -3135,7 +3159,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3396,7 +3419,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3734,6 +3756,15 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-node-es": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
|
||||
@@ -3924,7 +3955,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -4327,6 +4357,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
|
||||
"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^4.0.0",
|
||||
"universalify": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6 <7 || >=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-extra/node_modules/jsonfile": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
|
||||
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -4458,6 +4511,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -4568,7 +4627,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
@@ -4718,7 +4776,6 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -4747,8 +4804,7 @@
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/jsencrypt/-/jsencrypt-3.5.4.tgz",
|
||||
"integrity": "sha512-kNjfYEMNASxrDGsmcSQh/rUTmcoRfSUkxnAz+MMywM8jtGu+fFEZ3nJjHM58zscVnwR0fYmG9sGkTDjqUdpiwA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
@@ -4797,6 +4853,18 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonfile": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
|
||||
"integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^0.1.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -5314,7 +5382,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -5473,7 +5540,6 @@
|
||||
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -5605,7 +5671,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -5615,7 +5680,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -5751,6 +5815,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-twemoji": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/react-twemoji/-/react-twemoji-0.7.2.tgz",
|
||||
"integrity": "sha512-sNuzC0YIUtwWzoWq4cA6YCozIRu/9lDO8Anl2hlU5lQainZ2ZH6sB7wOv2wyTY462QSXqq9Y8T7rHgEjCfpVvg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@twemoji/api": "17.0.2",
|
||||
"dequal": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=5.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19.0.0",
|
||||
"react-dom": ">=19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -6233,7 +6314,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -6299,7 +6379,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -6339,6 +6418,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
|
||||
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -6458,7 +6546,6 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -6552,7 +6639,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -6686,7 +6772,6 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cabinet-frontend",
|
||||
"private": true,
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"description": "Remnawave Bedolaga Web App",
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
@@ -48,6 +48,7 @@
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-router": "^7.13.0",
|
||||
"react-twemoji": "^0.7.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
@@ -57,6 +58,7 @@
|
||||
"@types/node": "^25.2.1",
|
||||
"@types/react": "^19.2.13",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-twemoji": "^0.4.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
|
||||
@@ -18,6 +18,7 @@ import TelegramRedirect from './pages/TelegramRedirect';
|
||||
import DeepLinkRedirect from './pages/DeepLinkRedirect';
|
||||
import VerifyEmail from './pages/VerifyEmail';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
import OAuthCallback from './pages/OAuthCallback';
|
||||
|
||||
// User pages - lazy load
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
@@ -147,6 +148,7 @@ function App() {
|
||||
<Route path="/tg" element={<TelegramRedirect />} />
|
||||
<Route path="/connect" element={<DeepLinkRedirect />} />
|
||||
<Route path="/add" element={<DeepLinkRedirect />} />
|
||||
<Route path="/auth/oauth/callback" element={<OAuthCallback />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
onBackButtonClick,
|
||||
offBackButtonClick,
|
||||
} from '@telegram-apps/sdk-react';
|
||||
import Twemoji from 'react-twemoji';
|
||||
import App from './App';
|
||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||
import { PlatformProvider } from './platform/PlatformProvider';
|
||||
@@ -73,7 +74,9 @@ export function AppWithNavigator() {
|
||||
<TooltipProvider>
|
||||
<ToastProvider>
|
||||
<WebSocketProvider>
|
||||
<App />
|
||||
<Twemoji options={{ className: 'twemoji', folder: 'svg', ext: '.svg' }}>
|
||||
<App />
|
||||
</Twemoji>
|
||||
</WebSocketProvider>
|
||||
</ToastProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -101,6 +101,7 @@ export const adminApi = {
|
||||
per_page?: number;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
user_id?: number;
|
||||
} = {},
|
||||
): Promise<AdminTicketListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/tickets', { params });
|
||||
|
||||
@@ -101,6 +101,8 @@ export interface UserDetailResponse {
|
||||
used_promocodes: number;
|
||||
has_had_paid_subscription: boolean;
|
||||
lifetime_used_traffic_bytes: number;
|
||||
campaign_name: string | null;
|
||||
campaign_id: number | null;
|
||||
restriction_topup: boolean;
|
||||
restriction_subscription: boolean;
|
||||
restriction_reason: string | null;
|
||||
@@ -111,6 +113,36 @@ export interface UserDetailResponse {
|
||||
remnawave_uuid: string | null;
|
||||
}
|
||||
|
||||
export interface UserPanelInfo {
|
||||
found: boolean;
|
||||
trojan_password: string | null;
|
||||
vless_uuid: string | null;
|
||||
ss_password: string | null;
|
||||
subscription_url: string | null;
|
||||
happ_link: string | null;
|
||||
used_traffic_bytes: number;
|
||||
lifetime_used_traffic_bytes: number;
|
||||
traffic_limit_bytes: number;
|
||||
first_connected_at: string | null;
|
||||
online_at: string | null;
|
||||
last_connected_node_uuid: string | null;
|
||||
last_connected_node_name: string | null;
|
||||
}
|
||||
|
||||
export interface UserNodeUsageItem {
|
||||
node_uuid: string;
|
||||
node_name: string;
|
||||
country_code: string;
|
||||
total_bytes: number;
|
||||
daily_bytes: number[];
|
||||
}
|
||||
|
||||
export interface UserNodeUsageResponse {
|
||||
items: UserNodeUsageItem[];
|
||||
categories: string[];
|
||||
period_days: number;
|
||||
}
|
||||
|
||||
export interface UsersStatsResponse {
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
@@ -415,7 +447,7 @@ export const adminUsersApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete user
|
||||
// Delete user (soft delete, does NOT remove from Remnawave)
|
||||
deleteUser: async (
|
||||
userId: number,
|
||||
softDelete = true,
|
||||
@@ -427,6 +459,20 @@ export const adminUsersApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Full delete user (removes from bot DB + Remnawave panel)
|
||||
fullDeleteUser: async (
|
||||
userId: number,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
deleted_from_bot: boolean;
|
||||
deleted_from_panel: boolean;
|
||||
panel_error: string | null;
|
||||
}> => {
|
||||
const response = await apiClient.delete(`/cabinet/admin/users/${userId}/full`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get referrals
|
||||
getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, {
|
||||
@@ -494,4 +540,16 @@ export const adminUsersApi = {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/disable`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get panel info
|
||||
getPanelInfo: async (userId: number): Promise<UserPanelInfo> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/panel-info`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get node usage (always 30 days with daily breakdown)
|
||||
getNodeUsage: async (userId: number): Promise<UserNodeUsageResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/node-usage`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import apiClient from './client';
|
||||
import type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types';
|
||||
import type { AuthResponse, OAuthProvider, RegisterResponse, TokenResponse, User } from '../types';
|
||||
|
||||
export const authApi = {
|
||||
// Telegram WebApp authentication
|
||||
@@ -124,4 +124,31 @@ export const authApi = {
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// OAuth: get enabled providers
|
||||
getOAuthProviders: async (): Promise<{ providers: OAuthProvider[] }> => {
|
||||
const response = await apiClient.get<{ providers: OAuthProvider[] }>(
|
||||
'/cabinet/auth/oauth/providers',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// OAuth: get authorization URL
|
||||
getOAuthAuthorizeUrl: async (
|
||||
provider: string,
|
||||
): Promise<{ authorize_url: string; state: string }> => {
|
||||
const response = await apiClient.get<{ authorize_url: string; state: string }>(
|
||||
`/cabinet/auth/oauth/${provider}/authorize`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// OAuth: callback (exchange code for tokens)
|
||||
oauthCallback: async (provider: string, code: string, state: string): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>(
|
||||
`/cabinet/auth/oauth/${provider}/callback`,
|
||||
{ code, state },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -74,6 +74,7 @@ const AUTH_ENDPOINTS = [
|
||||
'/cabinet/auth/refresh',
|
||||
'/cabinet/auth/password/forgot',
|
||||
'/cabinet/auth/password/reset',
|
||||
'/cabinet/auth/oauth/',
|
||||
];
|
||||
|
||||
function isAuthEndpoint(url: string | undefined): boolean {
|
||||
|
||||
74
src/components/OAuthProviderIcon.tsx
Normal file
74
src/components/OAuthProviderIcon.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
interface OAuthProviderIconProps {
|
||||
provider: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function OAuthProviderIcon({
|
||||
provider,
|
||||
className = 'h-5 w-5',
|
||||
}: OAuthProviderIconProps) {
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'yandex':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12z"
|
||||
fill="#FC3F1D"
|
||||
/>
|
||||
<path
|
||||
d="M13.63 7.56c-.7 0-1.2.18-1.6.52-.4.34-.6.85-.6 1.5 0 .5.13.92.38 1.26.25.34.68.73 1.29 1.16l.55.4-2.45 5.1H9.6l2.12-4.32c-.83-.57-1.43-1.12-1.8-1.66-.37-.54-.56-1.2-.56-1.96 0-1.03.35-1.85 1.04-2.47.7-.62 1.65-.93 2.87-.93H15.4V17.5h-1.77V7.56z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'discord':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028 14.09 14.09 0 001.226-1.994.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
|
||||
fill="#5865F2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'vk':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2z"
|
||||
fill="#0077FF"
|
||||
/>
|
||||
<path
|
||||
d="M12.76 16.42h.86s.26-.03.39-.17c.12-.13.12-.37.12-.37s-.02-1.14.51-1.31c.53-.16 1.2 1.08 1.92 1.56.54.36.96.28.96.28l1.93-.03s1.01-.06.53-.84c-.04-.06-.28-.58-1.45-1.64-1.22-1.1-1.06-.93.41-2.84.9-1.16 1.26-1.87 1.15-2.17-.1-.29-.76-.21-.76-.21l-2.17.01s-.16-.02-.28.05c-.12.07-.19.23-.19.23s-.35.93-.81 1.72c-.98 1.67-1.37 1.76-1.53 1.65-.37-.24-.28-1-.28-1.53 0-1.66.25-2.35-.49-2.53-.25-.06-.43-.1-1.06-.1-.81-.01-1.5 0-1.89.19-.26.13-.46.42-.34.44.15.02.5.09.68.34.24.32.23 1.04.23 1.04s.14 1.96-.32 2.2c-.31.17-.74-.17-1.66-1.7-.47-.78-.83-1.65-.83-1.65s-.07-.17-.19-.26c-.15-.11-.36-.14-.36-.14l-2.06.01s-.31.01-.42.14c-.1.12-.01.36-.01.36s1.63 3.81 3.47 5.74c1.69 1.77 3.61 1.65 3.61 1.65z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
170
src/components/PaymentMethodIcon.tsx
Normal file
170
src/components/PaymentMethodIcon.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
interface PaymentMethodIconProps {
|
||||
method: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PaymentMethodIcon({
|
||||
method,
|
||||
className = 'h-8 w-8',
|
||||
}: PaymentMethodIconProps) {
|
||||
switch (method) {
|
||||
case 'telegram_stars':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="#229ED9" />
|
||||
<path
|
||||
d="M20 8l3.09 6.26L30 15.27l-5 4.87 1.18 6.88L20 23.77l-6.18 3.25L15 20.14l-5-4.87 6.91-1.01L20 8z"
|
||||
fill="#FFD700"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'cryptobot':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="#0088CC" />
|
||||
<path
|
||||
d="M20 7c-7.18 0-13 5.82-13 13s5.82 13 13 13 13-5.82 13-13S27.18 7 20 7zm1.5 18.75v2.5h-3v-2.4c-2.1-.3-3.8-1.2-4.6-2.1l1.8-2.4c.9.8 2.1 1.5 3.3 1.5 1.1 0 1.8-.5 1.8-1.3 0-.9-.8-1.3-2.5-1.9-2.3-.8-4.2-1.9-4.2-4.4 0-2.1 1.5-3.7 4.2-4.1v-2.4h3v2.3c1.6.2 2.9.9 3.8 1.7l-1.7 2.3c-.7-.6-1.7-1.1-2.8-1.1-1 0-1.6.4-1.6 1.2 0 .8.8 1.2 2.6 1.9 2.5.9 4.1 2 4.1 4.4 0 2.2-1.6 3.9-4.2 4.2z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'yookassa':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="#0070F0" />
|
||||
<g fill="#fff">
|
||||
<rect x="11" y="11" width="4" height="18" rx="2" />
|
||||
<circle cx="23" cy="20" r="7" fill="none" stroke="#fff" strokeWidth="3.5" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'heleket':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="#1A1A2E" />
|
||||
<path
|
||||
d="M20 8l2.47 5.01.55 1.12 1.23.18L30 15.27l-4 3.9-.89.87.21 1.22L26.18 27 20.9 24.22l-1.1-.58-1.1.58L13.62 27l.86-5.74.21-1.22-.89-.87-4-3.9 5.75-.96 1.23-.18.55-1.12L20 8z"
|
||||
fill="#00E68A"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'mulenpay':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="#FF4D4D" />
|
||||
<path
|
||||
d="M10 14h20c1.1 0 2 .9 2 2v8c0 1.1-.9 2-2 2H10c-1.1 0-2-.9-2-2v-8c0-1.1.9-2 2-2zm0 3v6h20v-6H10zm2 2h4v2h-4v-2z"
|
||||
fill="#fff"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'pal24':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="#00B341" />
|
||||
<g fill="#fff" fontFamily="Arial,sans-serif" fontWeight="700">
|
||||
<text x="20" y="26" textAnchor="middle" fontSize="16">
|
||||
P24
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'platega':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="#6C5CE7" />
|
||||
<path
|
||||
d="M14 12h8c3.31 0 6 2.69 6 6s-2.69 6-6 6h-4v4h-4V12zm4 8h4c1.1 0 2-.9 2-2s-.9-2-2-2h-4v4z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'wata':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="#2D7FF9" />
|
||||
<path
|
||||
d="M20 10c-.55 0-1 .35-1.27.8L12 24c-.4.7.1 1.5.9 1.5h14.2c.8 0 1.3-.8.9-1.5l-6.73-13.2c-.27-.45-.72-.8-1.27-.8z"
|
||||
fill="#fff"
|
||||
opacity=".9"
|
||||
/>
|
||||
<path
|
||||
d="M20 30c3.87 0 7-2.24 7-5 0-2.76-7-10-7-10s-7 7.24-7 10c0 2.76 3.13 5 7 5z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'freekassa':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="#FF6600" />
|
||||
<g fill="#fff" fontFamily="Arial,sans-serif" fontWeight="700">
|
||||
<text x="20" y="26" textAnchor="middle" fontSize="16">
|
||||
FK
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'cloudpayments':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="#004EBD" />
|
||||
<path
|
||||
d="M28.5 22c.83 0 1.5.67 1.5 1.5S29.33 25 28.5 25h-17c-1.93 0-3.5-1.57-3.5-3.5 0-1.72 1.24-3.15 2.88-3.45C11.15 15.17 13.86 13 17 13c2.48 0 4.65 1.3 5.89 3.25C23.56 15.48 24.7 15 26 15c2.76 0 5 2.24 5 5 0 .7-.15 1.37-.42 1.97-.02.01-.04.03-.08.03h-2z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'tribute':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="#7B61FF" />
|
||||
<path
|
||||
d="M20 10l2.94 5.96L29 17.02l-4.5 4.39L25.56 28 20 25.14 14.44 28l1.06-6.59L11 17.02l6.06-.96L20 10z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
case 'kassa_ai':
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 40 40">
|
||||
<defs>
|
||||
<linearGradient id="kassaAiGrad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#6366F1" />
|
||||
<stop offset="100%" stopColor="#8B5CF6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="20" cy="20" r="20" fill="url(#kassaAiGrad)" />
|
||||
<g fill="#fff" fontFamily="Arial,sans-serif" fontWeight="700">
|
||||
<text x="20" y="26" textAnchor="middle" fontSize="15">
|
||||
AI
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 40 40">
|
||||
<circle cx="20" cy="20" r="20" fill="#4B5563" />
|
||||
<path
|
||||
d="M10 14h20c1.1 0 2 .9 2 2v8c0 1.1-.9 2-2 2H10c-1.1 0-2-.9-2-2v-8c0-1.1.9-2 2-2zm0 3v6h20v-6H10z"
|
||||
fill="#fff"
|
||||
opacity=".7"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -85,8 +85,8 @@ export function ThemeTab() {
|
||||
colorsEqual(savedColorsRef.current, DEFAULT_THEME_COLORS)
|
||||
) {
|
||||
setDraftColors(colors);
|
||||
savedColorsRef.current = colors;
|
||||
}
|
||||
savedColorsRef.current = colors;
|
||||
}
|
||||
}, [serverColors]);
|
||||
|
||||
@@ -395,34 +395,32 @@ export function ThemeTab() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save / Cancel / Reset buttons */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{hasUnsavedChanges && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateColorsMutation.mutate(draftColors)}
|
||||
disabled={updateColorsMutation.isPending}
|
||||
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{updateColorsMutation.isPending
|
||||
? t('common.saving', t('common.save'))
|
||||
: t('common.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={updateColorsMutation.isPending}
|
||||
className="rounded-xl bg-dark-700 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save / Cancel — always visible when there are unsaved changes */}
|
||||
{hasUnsavedChanges && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={() => updateColorsMutation.mutate(draftColors)}
|
||||
disabled={updateColorsMutation.isPending}
|
||||
className="rounded-xl bg-accent-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
{updateColorsMutation.isPending
|
||||
? t('common.saving', t('common.save'))
|
||||
: t('common.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={updateColorsMutation.isPending}
|
||||
className="rounded-xl bg-dark-700 px-4 py-2 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset all colors */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
|
||||
@@ -121,6 +121,22 @@ const ShieldIcon = ({ className }: { className?: string }) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const InfoIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LogoutIcon = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
className={className}
|
||||
@@ -236,6 +252,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
||||
{ path: '/balance', label: t('nav.balance'), icon: CreditCardIcon },
|
||||
{ path: '/support', label: t('nav.support'), icon: ChatIcon },
|
||||
{ path: '/info', label: t('nav.info'), icon: InfoIcon },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
|
||||
];
|
||||
|
||||
|
||||
@@ -187,7 +187,13 @@
|
||||
"forgotPasswordHint": "Enter your email to receive a reset link",
|
||||
"loginSuccess": "Login successful",
|
||||
"passwordResetSent": "Password reset link sent to your email",
|
||||
"sendResetLink": "Send reset link"
|
||||
"sendResetLink": "Send reset link",
|
||||
"continueWithGoogle": "Continue with Google",
|
||||
"continueWithYandex": "Continue with Yandex",
|
||||
"continueWithDiscord": "Continue with Discord",
|
||||
"continueWithVk": "Continue with VK",
|
||||
"oauthError": "Authorization was denied or failed",
|
||||
"oauthExpired": "OAuth session expired. Please try again."
|
||||
},
|
||||
"emailVerification": {
|
||||
"title": "Email Verification",
|
||||
@@ -1093,7 +1099,12 @@
|
||||
"emailContent": "Email content",
|
||||
"emailContentHint": "HTML supported",
|
||||
"emailContentPlaceholder": "<p>Hello, {{user_name}}!</p>\n<p>Your message here...</p>",
|
||||
"emailVariables": "Available variables"
|
||||
"emailVariables": "Available variables",
|
||||
"enableTelegram": "Telegram",
|
||||
"enableEmail": "Email",
|
||||
"sendingBoth": "2 broadcasts will be created",
|
||||
"bothCreated": "Broadcasts created",
|
||||
"atLeastOneChannel": "Select at least one channel"
|
||||
},
|
||||
"settings": {
|
||||
"title": "System Settings",
|
||||
@@ -1267,6 +1278,7 @@
|
||||
"reminderCooldownDesc": "Minimum time between reminders (1-120 minutes)",
|
||||
"settingsUpdateError": "Error saving settings",
|
||||
"copyTelegramId": "Click to copy Telegram ID",
|
||||
"viewUser": "Profile",
|
||||
"cabinetNotifications": "Cabinet Notifications",
|
||||
"userNotificationsEnabled": "User Notifications",
|
||||
"userNotificationsEnabledDesc": "Send notifications to users about admin replies",
|
||||
@@ -1808,7 +1820,7 @@
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Delete user?",
|
||||
"message": "Are you sure? This action is irreversible. All user data will be deleted."
|
||||
"message": "Are you sure? This action is irreversible. The user will be fully deleted from the bot and from the Remnawave panel."
|
||||
},
|
||||
"confirmResetTrial": {
|
||||
"title": "Reset trial?",
|
||||
@@ -1828,14 +1840,36 @@
|
||||
"info": "Information",
|
||||
"subscription": "Subscription",
|
||||
"balance": "Balance",
|
||||
"sync": "Synchronization"
|
||||
"sync": "Synchronization",
|
||||
"tickets": "Tickets"
|
||||
},
|
||||
"noTickets": "No tickets from this user",
|
||||
"ticketsCount": "tickets",
|
||||
"messagesCount": "messages",
|
||||
"status": "Status",
|
||||
"language": "Language",
|
||||
"registration": "Registration",
|
||||
"lastActivity": "Last activity",
|
||||
"totalSpent": "Total spent",
|
||||
"purchases": "Purchases",
|
||||
"campaign": "Advertising campaign",
|
||||
"referralsList": "Referrals list",
|
||||
"referralEarnings": "Referral earnings",
|
||||
"noReferrals": "No referrals",
|
||||
"panelConfig": "Panel configuration",
|
||||
"trojanPassword": "Trojan password",
|
||||
"vlessUuid": "VLESS UUID",
|
||||
"ssPassword": "SS password",
|
||||
"subscriptionUrl": "Subscription URL",
|
||||
"happLink": "HAPP link",
|
||||
"firstConnected": "First connected",
|
||||
"lastOnline": "Last online",
|
||||
"lastNode": "Last node",
|
||||
"liveTraffic": "Current traffic",
|
||||
"lifetime": "Lifetime",
|
||||
"nodeUsage": "Node usage",
|
||||
"copied": "Copied",
|
||||
"panelNotFound": "User not found in panel",
|
||||
"referral": {
|
||||
"title": "Referral program",
|
||||
"referrals": "Referrals",
|
||||
@@ -1848,6 +1882,10 @@
|
||||
"subscription": "• Subscription purchase prohibited",
|
||||
"reason": "Reason"
|
||||
},
|
||||
"actions": {
|
||||
"title": "Actions",
|
||||
"areYouSure": "Are you sure?"
|
||||
},
|
||||
"subscription": {
|
||||
"current": "Current subscription",
|
||||
"tariff": "Tariff",
|
||||
|
||||
@@ -181,7 +181,13 @@
|
||||
"passwordTooShort": "رمز عبور باید حداقل ۸ کاراکتر باشد",
|
||||
"sendResetLink": "ارسال لینک بازنشانی",
|
||||
"tooManyAttempts": "تلاشهای زیاد. لطفاً بعداً امتحان کنید",
|
||||
"verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد"
|
||||
"verificationEmailNotice": "پس از ثبت نام، ایمیل تأیید به آدرس شما ارسال خواهد شد",
|
||||
"continueWithGoogle": "ادامه با Google",
|
||||
"continueWithYandex": "ادامه با Yandex",
|
||||
"continueWithDiscord": "ادامه با Discord",
|
||||
"continueWithVk": "ادامه با VK",
|
||||
"oauthError": "مجوز رد شد یا ناموفق بود",
|
||||
"oauthExpired": "نشست OAuth منقضی شده است. لطفاً دوباره امتحان کنید."
|
||||
},
|
||||
"emailVerification": {
|
||||
"title": "تایید ایمیل",
|
||||
@@ -797,7 +803,12 @@
|
||||
"activity": "بر اساس فعالیت",
|
||||
"source": "بر اساس منبع",
|
||||
"tariff": "بر اساس تعرفه"
|
||||
}
|
||||
},
|
||||
"enableTelegram": "Telegram",
|
||||
"enableEmail": "Email",
|
||||
"sendingBoth": "۲ پیامرسانی ایجاد خواهد شد",
|
||||
"bothCreated": "پیامرسانیها ایجاد شدند",
|
||||
"atLeastOneChannel": "حداقل یک کانال را انتخاب کنید"
|
||||
},
|
||||
"payments": {
|
||||
"title": "تأیید پرداخت",
|
||||
@@ -979,6 +990,7 @@
|
||||
"adminNotificationsEnabledDesc": "ارسال اعلان به مدیران درباره تیکتها و پاسخهای جدید",
|
||||
"cabinetNotifications": "اعلانهای پنل",
|
||||
"copyTelegramId": "برای کپی شناسه تلگرام کلیک کنید",
|
||||
"viewUser": "کاربر",
|
||||
"userNotificationsEnabled": "اعلانهای کاربر",
|
||||
"userNotificationsEnabledDesc": "ارسال اعلان به کاربران درباره پاسخهای مدیر"
|
||||
},
|
||||
@@ -1531,14 +1543,36 @@
|
||||
"info": "اطلاعات",
|
||||
"subscription": "اشتراک",
|
||||
"balance": "موجودی",
|
||||
"sync": "همگامسازی"
|
||||
"sync": "همگامسازی",
|
||||
"tickets": "تیکتها"
|
||||
},
|
||||
"noTickets": "این کاربر تیکتی ندارد",
|
||||
"ticketsCount": "تیکت",
|
||||
"messagesCount": "پیام",
|
||||
"status": "وضعیت",
|
||||
"language": "زبان",
|
||||
"registration": "ثبتنام",
|
||||
"lastActivity": "آخرین فعالیت",
|
||||
"totalSpent": "کل هزینه",
|
||||
"purchases": "خریدها",
|
||||
"campaign": "کمپین تبلیغاتی",
|
||||
"referralsList": "لیست ارجاعات",
|
||||
"referralEarnings": "درآمد ارجاع",
|
||||
"noReferrals": "بدون ارجاع",
|
||||
"panelConfig": "پیکربندی پنل",
|
||||
"trojanPassword": "رمز Trojan",
|
||||
"vlessUuid": "VLESS UUID",
|
||||
"ssPassword": "رمز SS",
|
||||
"subscriptionUrl": "لینک اشتراک",
|
||||
"happLink": "لینک HAPP",
|
||||
"firstConnected": "اولین اتصال",
|
||||
"lastOnline": "آخرین آنلاین",
|
||||
"lastNode": "آخرین نود",
|
||||
"liveTraffic": "ترافیک فعلی",
|
||||
"lifetime": "کل",
|
||||
"nodeUsage": "مصرف نودها",
|
||||
"copied": "کپی شد",
|
||||
"panelNotFound": "کاربر در پنل یافت نشد",
|
||||
"referral": {
|
||||
"title": "برنامه ارجاع",
|
||||
"referrals": "ارجاعات",
|
||||
|
||||
@@ -190,7 +190,13 @@
|
||||
"clickLinkToVerify": "Перейдите по ссылке в письме, чтобы подтвердить аккаунт и войти.",
|
||||
"backToLogin": "Вернуться ко входу",
|
||||
"emailNotVerified": "Сначала подтвердите email",
|
||||
"loginSuccess": "Авторизация успешна"
|
||||
"loginSuccess": "Авторизация успешна",
|
||||
"continueWithGoogle": "Продолжить через Google",
|
||||
"continueWithYandex": "Продолжить через Яндекс",
|
||||
"continueWithDiscord": "Продолжить через Discord",
|
||||
"continueWithVk": "Продолжить через VK",
|
||||
"oauthError": "Авторизация отклонена или не удалась",
|
||||
"oauthExpired": "Сессия OAuth истекла. Попробуйте снова."
|
||||
},
|
||||
"emailVerification": {
|
||||
"title": "Подтверждение email",
|
||||
@@ -1118,7 +1124,12 @@
|
||||
"emailContent": "Содержимое письма",
|
||||
"emailContentHint": "Поддерживается HTML",
|
||||
"emailContentPlaceholder": "<p>Здравствуйте, {{user_name}}!</p>\n<p>Текст вашего письма...</p>",
|
||||
"emailVariables": "Доступные переменные"
|
||||
"emailVariables": "Доступные переменные",
|
||||
"enableTelegram": "Telegram",
|
||||
"enableEmail": "Email",
|
||||
"sendingBoth": "Будет создано 2 рассылки",
|
||||
"bothCreated": "Рассылки созданы",
|
||||
"atLeastOneChannel": "Выберите хотя бы один канал"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки системы",
|
||||
@@ -1782,6 +1793,7 @@
|
||||
"reminderCooldownDesc": "Минимальное время между напоминаниями (1-120 минут)",
|
||||
"settingsUpdateError": "Ошибка сохранения настроек",
|
||||
"copyTelegramId": "Нажмите чтобы скопировать Telegram ID",
|
||||
"viewUser": "Карточка",
|
||||
"cabinetNotifications": "Уведомления в кабинете",
|
||||
"userNotificationsEnabled": "Уведомления для пользователей",
|
||||
"userNotificationsEnabledDesc": "Отправлять уведомления пользователям об ответах админа",
|
||||
@@ -2335,7 +2347,7 @@
|
||||
},
|
||||
"confirmDelete": {
|
||||
"title": "Удалить пользователя?",
|
||||
"message": "Вы уверены? Это действие необратимо. Все данные пользователя будут удалены."
|
||||
"message": "Вы уверены? Это действие необратимо. Пользователь будет полностью удалён из бота и из панели Remnawave."
|
||||
},
|
||||
"confirmResetTrial": {
|
||||
"title": "Сбросить триал?",
|
||||
@@ -2355,14 +2367,35 @@
|
||||
"info": "Информация",
|
||||
"subscription": "Подписка",
|
||||
"balance": "Баланс",
|
||||
"sync": "Синхронизация"
|
||||
"sync": "Синхронизация",
|
||||
"tickets": "Тикеты"
|
||||
},
|
||||
"noTickets": "У пользователя нет тикетов",
|
||||
"ticketsCount": "тикетов",
|
||||
"messagesCount": "сообщений",
|
||||
"status": "Статус",
|
||||
"language": "Язык",
|
||||
"registration": "Регистрация",
|
||||
"lastActivity": "Последняя активность",
|
||||
"totalSpent": "Всего потрачено",
|
||||
"purchases": "Покупок",
|
||||
"campaign": "Рекламная кампания",
|
||||
"referralsList": "Список рефералов",
|
||||
"noReferrals": "Нет рефералов",
|
||||
"panelConfig": "Конфигурация панели",
|
||||
"trojanPassword": "Trojan пароль",
|
||||
"vlessUuid": "VLESS UUID",
|
||||
"ssPassword": "SS пароль",
|
||||
"subscriptionUrl": "Ссылка подписки",
|
||||
"happLink": "HAPP ссылка",
|
||||
"firstConnected": "Первое подключение",
|
||||
"lastOnline": "Последний онлайн",
|
||||
"lastNode": "Последняя нода",
|
||||
"liveTraffic": "Текущий трафик",
|
||||
"lifetime": "За всё время",
|
||||
"nodeUsage": "Расход по нодам",
|
||||
"copied": "Скопировано",
|
||||
"panelNotFound": "Пользователь не найден в панели",
|
||||
"referral": {
|
||||
"title": "Реферальная программа",
|
||||
"referrals": "Рефералов",
|
||||
@@ -2375,6 +2408,10 @@
|
||||
"subscription": "• Запрет покупки подписки",
|
||||
"reason": "Причина"
|
||||
},
|
||||
"actions": {
|
||||
"title": "Действия",
|
||||
"areYouSure": "Уверены?"
|
||||
},
|
||||
"subscription": {
|
||||
"current": "Текущая подписка",
|
||||
"tariff": "Тариф",
|
||||
|
||||
@@ -181,7 +181,13 @@
|
||||
"passwordTooShort": "密码至少需要8个字符",
|
||||
"sendResetLink": "发送重置链接",
|
||||
"tooManyAttempts": "尝试次数过多,请稍后再试",
|
||||
"verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱"
|
||||
"verificationEmailNotice": "注册后,验证邮件将发送到您的邮箱",
|
||||
"continueWithGoogle": "通过 Google 继续",
|
||||
"continueWithYandex": "通过 Yandex 继续",
|
||||
"continueWithDiscord": "通过 Discord 继续",
|
||||
"continueWithVk": "通过 VK 继续",
|
||||
"oauthError": "授权被拒绝或失败",
|
||||
"oauthExpired": "OAuth 会话已过期。请重试。"
|
||||
},
|
||||
"emailVerification": {
|
||||
"title": "邮箱验证",
|
||||
@@ -849,7 +855,12 @@
|
||||
"activity": "按活动",
|
||||
"source": "按来源",
|
||||
"tariff": "按套餐"
|
||||
}
|
||||
},
|
||||
"enableTelegram": "Telegram",
|
||||
"enableEmail": "Email",
|
||||
"sendingBoth": "将创建2个群发",
|
||||
"bothCreated": "群发已创建",
|
||||
"atLeastOneChannel": "请至少选择一个渠道"
|
||||
},
|
||||
"settings": {
|
||||
"title": "系统设置",
|
||||
@@ -1017,6 +1028,7 @@
|
||||
"adminNotificationsEnabledDesc": "向管理员发送新工单和回复通知",
|
||||
"cabinetNotifications": "面板通知",
|
||||
"copyTelegramId": "点击复制Telegram ID",
|
||||
"viewUser": "用户",
|
||||
"userNotificationsEnabled": "用户通知",
|
||||
"userNotificationsEnabledDesc": "向用户发送管理员回复通知"
|
||||
},
|
||||
@@ -1530,14 +1542,36 @@
|
||||
"info": "信息",
|
||||
"subscription": "订阅",
|
||||
"balance": "余额",
|
||||
"sync": "同步"
|
||||
"sync": "同步",
|
||||
"tickets": "工单"
|
||||
},
|
||||
"noTickets": "该用户没有工单",
|
||||
"ticketsCount": "个工单",
|
||||
"messagesCount": "条消息",
|
||||
"status": "状态",
|
||||
"language": "语言",
|
||||
"registration": "注册时间",
|
||||
"lastActivity": "最后活跃",
|
||||
"totalSpent": "总消费",
|
||||
"purchases": "购买次数",
|
||||
"campaign": "广告活动",
|
||||
"referralsList": "推荐列表",
|
||||
"referralEarnings": "推荐收入",
|
||||
"noReferrals": "暂无推荐",
|
||||
"panelConfig": "面板配置",
|
||||
"trojanPassword": "Trojan 密码",
|
||||
"vlessUuid": "VLESS UUID",
|
||||
"ssPassword": "SS 密码",
|
||||
"subscriptionUrl": "订阅链接",
|
||||
"happLink": "HAPP 链接",
|
||||
"firstConnected": "首次连接",
|
||||
"lastOnline": "最后在线",
|
||||
"lastNode": "最后节点",
|
||||
"liveTraffic": "当前流量",
|
||||
"lifetime": "累计",
|
||||
"nodeUsage": "节点用量",
|
||||
"copied": "已复制",
|
||||
"panelNotFound": "面板中未找到用户",
|
||||
"referral": {
|
||||
"title": "推荐计划",
|
||||
"referrals": "推荐人数",
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
adminBroadcastsApi,
|
||||
BroadcastFilter,
|
||||
TariffFilter,
|
||||
BroadcastChannel,
|
||||
CombinedBroadcastCreateRequest,
|
||||
} from '../api/adminBroadcasts';
|
||||
import { AdminBackButton } from '../components/admin';
|
||||
@@ -118,12 +117,15 @@ export default function AdminBroadcastCreate() {
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Channel selection
|
||||
const [channel, setChannel] = useState<BroadcastChannel>('telegram');
|
||||
// Channel toggles (both can be enabled)
|
||||
const [telegramEnabled, setTelegramEnabled] = useState(true);
|
||||
const [emailEnabled, setEmailEnabled] = useState(false);
|
||||
|
||||
// Common state
|
||||
const [target, setTarget] = useState('');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
// Separate targets per channel
|
||||
const [telegramTarget, setTelegramTarget] = useState('');
|
||||
const [emailTarget, setEmailTarget] = useState('');
|
||||
const [showTelegramFilters, setShowTelegramFilters] = useState(false);
|
||||
const [showEmailFilters, setShowEmailFilters] = useState(false);
|
||||
|
||||
// Telegram-specific state
|
||||
const [messageText, setMessageText] = useState('');
|
||||
@@ -138,29 +140,32 @@ export default function AdminBroadcastCreate() {
|
||||
const [emailSubject, setEmailSubject] = useState('');
|
||||
const [emailContent, setEmailContent] = useState('');
|
||||
|
||||
// Submitting state for dual send
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Fetch Telegram filters
|
||||
const { data: filtersData, isLoading: filtersLoading } = useQuery({
|
||||
queryKey: ['admin', 'broadcasts', 'filters'],
|
||||
queryFn: adminBroadcastsApi.getFilters,
|
||||
enabled: channel === 'telegram',
|
||||
enabled: telegramEnabled,
|
||||
});
|
||||
|
||||
// Fetch Email filters
|
||||
const { data: emailFiltersData, isLoading: emailFiltersLoading } = useQuery({
|
||||
queryKey: ['admin', 'broadcasts', 'email-filters'],
|
||||
queryFn: adminBroadcastsApi.getEmailFilters,
|
||||
enabled: channel === 'email',
|
||||
enabled: emailEnabled,
|
||||
});
|
||||
|
||||
// Fetch buttons
|
||||
const { data: buttonsData } = useQuery({
|
||||
queryKey: ['admin', 'broadcasts', 'buttons'],
|
||||
queryFn: adminBroadcastsApi.getButtons,
|
||||
enabled: channel === 'telegram',
|
||||
enabled: telegramEnabled,
|
||||
});
|
||||
|
||||
// Preview mutations
|
||||
const previewMutation = useMutation({
|
||||
// Preview mutations — separate for each channel
|
||||
const telegramPreviewMutation = useMutation({
|
||||
mutationFn: adminBroadcastsApi.preview,
|
||||
});
|
||||
|
||||
@@ -168,7 +173,7 @@ export default function AdminBroadcastCreate() {
|
||||
mutationFn: adminBroadcastsApi.previewEmail,
|
||||
});
|
||||
|
||||
// Create mutation
|
||||
// Create mutation (used for single-channel sends)
|
||||
const createMutation = useMutation({
|
||||
mutationFn: adminBroadcastsApi.createCombined,
|
||||
onSuccess: (data) => {
|
||||
@@ -178,7 +183,7 @@ export default function AdminBroadcastCreate() {
|
||||
});
|
||||
|
||||
// Group Telegram filters
|
||||
const groupedFilters = useMemo(() => {
|
||||
const groupedTelegramFilters = useMemo(() => {
|
||||
if (!filtersData) return {};
|
||||
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {};
|
||||
|
||||
@@ -215,48 +220,46 @@ export default function AdminBroadcastCreate() {
|
||||
return groups;
|
||||
}, [emailFiltersData]);
|
||||
|
||||
// Current filters based on channel
|
||||
const currentFilters = channel === 'telegram' ? groupedFilters : groupedEmailFilters;
|
||||
const isFiltersLoading = channel === 'telegram' ? filtersLoading : emailFiltersLoading;
|
||||
// Selected filter info for each channel
|
||||
const selectedTelegramFilter = useMemo(() => {
|
||||
if (!telegramTarget || !filtersData) return null;
|
||||
const all = [
|
||||
...filtersData.filters,
|
||||
...filtersData.tariff_filters,
|
||||
...filtersData.custom_filters,
|
||||
];
|
||||
return all.find((f) => f.key === telegramTarget) ?? null;
|
||||
}, [telegramTarget, filtersData]);
|
||||
|
||||
// Selected filter info
|
||||
const selectedFilter = useMemo(() => {
|
||||
if (!target) return null;
|
||||
const selectedEmailFilter = useMemo(() => {
|
||||
if (!emailTarget || !emailFiltersData) return null;
|
||||
return emailFiltersData.filters.find((f) => f.key === emailTarget) ?? null;
|
||||
}, [emailTarget, emailFiltersData]);
|
||||
|
||||
if (channel === 'telegram' && filtersData) {
|
||||
const all = [
|
||||
...filtersData.filters,
|
||||
...filtersData.tariff_filters,
|
||||
...filtersData.custom_filters,
|
||||
];
|
||||
return all.find((f) => f.key === target);
|
||||
}
|
||||
// Handle toggling channels
|
||||
const handleToggleTelegram = () => {
|
||||
setTelegramEnabled((prev) => !prev);
|
||||
setTelegramTarget('');
|
||||
telegramPreviewMutation.reset();
|
||||
};
|
||||
|
||||
if (channel === 'email' && emailFiltersData) {
|
||||
return emailFiltersData.filters.find((f) => f.key === target);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [target, channel, filtersData, emailFiltersData]);
|
||||
|
||||
// Handle channel change
|
||||
const handleChannelChange = (newChannel: BroadcastChannel) => {
|
||||
setChannel(newChannel);
|
||||
setTarget('');
|
||||
previewMutation.reset();
|
||||
const handleToggleEmail = () => {
|
||||
setEmailEnabled((prev) => !prev);
|
||||
setEmailTarget('');
|
||||
emailPreviewMutation.reset();
|
||||
};
|
||||
|
||||
// Handle filter selection
|
||||
const handleFilterSelect = (filterKey: string) => {
|
||||
setTarget(filterKey);
|
||||
setShowFilters(false);
|
||||
// Handle filter selection per channel
|
||||
const handleTelegramFilterSelect = (filterKey: string) => {
|
||||
setTelegramTarget(filterKey);
|
||||
setShowTelegramFilters(false);
|
||||
telegramPreviewMutation.mutate(filterKey);
|
||||
};
|
||||
|
||||
if (channel === 'telegram') {
|
||||
previewMutation.mutate(filterKey);
|
||||
} else {
|
||||
emailPreviewMutation.mutate(filterKey);
|
||||
}
|
||||
const handleEmailFilterSelect = (filterKey: string) => {
|
||||
setEmailTarget(filterKey);
|
||||
setShowEmailFilters(false);
|
||||
emailPreviewMutation.mutate(filterKey);
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
@@ -308,53 +311,163 @@ export default function AdminBroadcastCreate() {
|
||||
};
|
||||
|
||||
// Validate form
|
||||
const isTelegramValid = telegramEnabled && telegramTarget && messageText.trim().length > 0;
|
||||
const isEmailValid =
|
||||
emailEnabled && emailTarget && emailSubject.trim().length > 0 && emailContent.trim().length > 0;
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
if (!target) return false;
|
||||
if (!telegramEnabled && !emailEnabled) return false;
|
||||
if (telegramEnabled && !isTelegramValid) return false;
|
||||
if (emailEnabled && !isEmailValid) return false;
|
||||
return true;
|
||||
}, [telegramEnabled, emailEnabled, isTelegramValid, isEmailValid]);
|
||||
|
||||
if (channel === 'telegram') {
|
||||
return messageText.trim().length > 0;
|
||||
}
|
||||
|
||||
if (channel === 'email') {
|
||||
return emailSubject.trim().length > 0 && emailContent.trim().length > 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [target, channel, messageText, emailSubject, emailContent]);
|
||||
const bothChannels = telegramEnabled && emailEnabled;
|
||||
|
||||
// Submit
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
if (!isValid) return;
|
||||
|
||||
const data: CombinedBroadcastCreateRequest = {
|
||||
channel,
|
||||
target,
|
||||
};
|
||||
|
||||
if (channel === 'telegram') {
|
||||
data.message_text = messageText;
|
||||
data.selected_buttons = selectedButtons;
|
||||
|
||||
// Single channel — use existing createMutation with navigation to detail
|
||||
if (telegramEnabled && !emailEnabled) {
|
||||
const data: CombinedBroadcastCreateRequest = {
|
||||
channel: 'telegram',
|
||||
target: telegramTarget,
|
||||
message_text: messageText,
|
||||
selected_buttons: selectedButtons,
|
||||
};
|
||||
if (uploadedFileId) {
|
||||
data.media = {
|
||||
type: mediaType,
|
||||
file_id: uploadedFileId,
|
||||
};
|
||||
data.media = { type: mediaType, file_id: uploadedFileId };
|
||||
}
|
||||
createMutation.mutate(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (channel === 'email') {
|
||||
data.email_subject = emailSubject;
|
||||
data.email_html_content = emailContent;
|
||||
if (emailEnabled && !telegramEnabled) {
|
||||
const data: CombinedBroadcastCreateRequest = {
|
||||
channel: 'email',
|
||||
target: emailTarget,
|
||||
email_subject: emailSubject,
|
||||
email_html_content: emailContent,
|
||||
};
|
||||
createMutation.mutate(data);
|
||||
return;
|
||||
}
|
||||
|
||||
createMutation.mutate(data);
|
||||
// Both channels — two sequential requests, navigate to list
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const telegramData: CombinedBroadcastCreateRequest = {
|
||||
channel: 'telegram',
|
||||
target: telegramTarget,
|
||||
message_text: messageText,
|
||||
selected_buttons: selectedButtons,
|
||||
};
|
||||
if (uploadedFileId) {
|
||||
telegramData.media = { type: mediaType, file_id: uploadedFileId };
|
||||
}
|
||||
|
||||
const emailData: CombinedBroadcastCreateRequest = {
|
||||
channel: 'email',
|
||||
target: emailTarget,
|
||||
email_subject: emailSubject,
|
||||
email_html_content: emailContent,
|
||||
};
|
||||
|
||||
await adminBroadcastsApi.createCombined(telegramData);
|
||||
await adminBroadcastsApi.createCombined(emailData);
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
|
||||
navigate('/admin/broadcasts');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const recipientsCount =
|
||||
channel === 'telegram'
|
||||
? (previewMutation.data?.count ?? selectedFilter?.count ?? null)
|
||||
: (emailPreviewMutation.data?.count ?? selectedFilter?.count ?? null);
|
||||
// Recipients counts per channel
|
||||
const telegramRecipientsCount = telegramEnabled
|
||||
? (telegramPreviewMutation.data?.count ?? selectedTelegramFilter?.count ?? null)
|
||||
: null;
|
||||
|
||||
const emailRecipientsCount = emailEnabled
|
||||
? (emailPreviewMutation.data?.count ?? selectedEmailFilter?.count ?? null)
|
||||
: null;
|
||||
|
||||
const isPending = createMutation.isPending || isSubmitting;
|
||||
|
||||
// Render filter dropdown
|
||||
const renderFilterDropdown = (
|
||||
channelType: 'telegram' | 'email',
|
||||
target: string,
|
||||
selectedFilter: BroadcastFilter | TariffFilter | null,
|
||||
recipientsCount: number | null,
|
||||
showFilters: boolean,
|
||||
setShowFilters: (v: boolean) => void,
|
||||
handleFilterSelect: (key: string) => void,
|
||||
groupedFilters: Record<string, (BroadcastFilter | TariffFilter)[]>,
|
||||
isLoading: boolean,
|
||||
) => (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{channelType === 'telegram'
|
||||
? t('admin.broadcasts.selectFilter')
|
||||
: t('admin.broadcasts.selectEmailFilter')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-dark-700 bg-dark-800 p-3 text-left transition-colors hover:border-dark-600"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<UsersIcon />
|
||||
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
|
||||
{selectedFilter
|
||||
? selectedFilter.label
|
||||
: channelType === 'telegram'
|
||||
? t('admin.broadcasts.selectFilterPlaceholder')
|
||||
: t('admin.broadcasts.selectEmailFilterPlaceholder')}
|
||||
</span>
|
||||
{recipientsCount !== null && (
|
||||
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||
{recipientsCount} {t('admin.broadcasts.recipients')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDownIcon />
|
||||
</button>
|
||||
|
||||
{showFilters && (
|
||||
<div className="absolute left-0 right-0 top-full z-10 mt-1 max-h-64 overflow-y-auto rounded-lg border border-dark-700 bg-dark-800 shadow-xl">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-dark-400">{t('common.loading')}</div>
|
||||
) : (
|
||||
Object.entries(groupedFilters).map(([group, filters]) => (
|
||||
<div key={group}>
|
||||
<div className="sticky top-0 bg-dark-900 px-3 py-2 text-xs font-medium text-dark-400">
|
||||
{FILTER_GROUP_LABEL_KEYS[group] ? t(FILTER_GROUP_LABEL_KEYS[group]) : group}
|
||||
</div>
|
||||
{filters.map((filter) => (
|
||||
<button
|
||||
key={filter.key}
|
||||
onClick={() => handleFilterSelect(filter.key)}
|
||||
className={`flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-dark-700 ${
|
||||
target === filter.key ? 'bg-accent-500/20' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-dark-100">{filter.label}</span>
|
||||
{filter.count !== null && filter.count !== undefined && (
|
||||
<span className="text-xs text-dark-400">{filter.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -372,273 +485,250 @@ export default function AdminBroadcastCreate() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel selection */}
|
||||
{/* Channel toggles */}
|
||||
<div className="card">
|
||||
<label className="mb-3 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.selectChannel')}
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleChannelChange('telegram')}
|
||||
onClick={handleToggleTelegram}
|
||||
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${
|
||||
channel === 'telegram'
|
||||
telegramEnabled
|
||||
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
|
||||
}`}
|
||||
>
|
||||
<TelegramIcon />
|
||||
<span className="font-medium">{t('admin.broadcasts.channel.telegram')}</span>
|
||||
<span className="font-medium">{t('admin.broadcasts.enableTelegram')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleChannelChange('email')}
|
||||
onClick={handleToggleEmail}
|
||||
className={`flex flex-1 items-center justify-center gap-2 rounded-lg border p-4 transition-all ${
|
||||
channel === 'email'
|
||||
emailEnabled
|
||||
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
|
||||
}`}
|
||||
>
|
||||
<EmailIcon />
|
||||
<span className="font-medium">{t('admin.broadcasts.channel.email')}</span>
|
||||
<span className="font-medium">{t('admin.broadcasts.enableEmail')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{!telegramEnabled && !emailEnabled && (
|
||||
<p className="mt-2 text-sm text-error-400">{t('admin.broadcasts.atLeastOneChannel')}</p>
|
||||
)}
|
||||
{bothChannels && (
|
||||
<p className="mt-2 text-sm text-accent-400">{t('admin.broadcasts.sendingBoth')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="card space-y-6">
|
||||
{/* Section title */}
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{channel === 'telegram'
|
||||
? t('admin.broadcasts.telegramSection')
|
||||
: t('admin.broadcasts.emailSection')}
|
||||
</h2>
|
||||
{/* Telegram section */}
|
||||
{telegramEnabled && (
|
||||
<div className="card space-y-6">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.broadcasts.telegramSection')}
|
||||
</h2>
|
||||
|
||||
{/* Filter selection */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{channel === 'telegram'
|
||||
? t('admin.broadcasts.selectFilter')
|
||||
: t('admin.broadcasts.selectEmailFilter')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-dark-700 bg-dark-800 p-3 text-left transition-colors hover:border-dark-600"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<UsersIcon />
|
||||
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
|
||||
{selectedFilter
|
||||
? selectedFilter.label
|
||||
: channel === 'telegram'
|
||||
? t('admin.broadcasts.selectFilterPlaceholder')
|
||||
: t('admin.broadcasts.selectEmailFilterPlaceholder')}
|
||||
</span>
|
||||
{recipientsCount !== null && (
|
||||
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||
{recipientsCount} {t('admin.broadcasts.recipients')}
|
||||
</span>
|
||||
{/* Telegram filter selection */}
|
||||
{renderFilterDropdown(
|
||||
'telegram',
|
||||
telegramTarget,
|
||||
selectedTelegramFilter,
|
||||
telegramRecipientsCount,
|
||||
showTelegramFilters,
|
||||
setShowTelegramFilters,
|
||||
handleTelegramFilterSelect,
|
||||
groupedTelegramFilters,
|
||||
filtersLoading,
|
||||
)}
|
||||
|
||||
{/* Message text */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.messageText')}
|
||||
</label>
|
||||
<textarea
|
||||
value={messageText}
|
||||
onChange={(e) => setMessageText(e.target.value)}
|
||||
placeholder={t('admin.broadcasts.messageTextPlaceholder')}
|
||||
rows={6}
|
||||
maxLength={4000}
|
||||
className="input min-h-[150px] resize-y"
|
||||
/>
|
||||
<div className="mt-1 text-right text-xs text-dark-400">{messageText.length}/4000</div>
|
||||
</div>
|
||||
|
||||
{/* Media upload */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.media')}
|
||||
</label>
|
||||
{mediaFile ? (
|
||||
<div className="rounded-lg border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{mediaType === 'photo' && <PhotoIcon />}
|
||||
{mediaType === 'video' && <VideoIcon />}
|
||||
{mediaType === 'document' && <DocumentIcon />}
|
||||
<div>
|
||||
<p className="text-sm text-dark-100">{mediaFile.name}</p>
|
||||
<p className="text-xs text-dark-400">
|
||||
{(mediaFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemoveMedia}
|
||||
className="rounded-lg p-2 text-dark-400 hover:bg-dark-700 hover:text-error-400"
|
||||
disabled={isUploading}
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
{mediaPreview && (
|
||||
<img
|
||||
src={mediaPreview}
|
||||
alt="Preview"
|
||||
className="mt-3 max-h-48 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
{isUploading && (
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-accent-400">
|
||||
<RefreshIcon />
|
||||
{t('admin.broadcasts.uploading')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDownIcon />
|
||||
</button>
|
||||
|
||||
{showFilters && (
|
||||
<div className="absolute left-0 right-0 top-full z-10 mt-1 max-h-64 overflow-y-auto rounded-lg border border-dark-700 bg-dark-800 shadow-xl">
|
||||
{isFiltersLoading ? (
|
||||
<div className="p-4 text-center text-dark-400">{t('common.loading')}</div>
|
||||
) : (
|
||||
Object.entries(currentFilters).map(([group, filters]) => (
|
||||
<div key={group}>
|
||||
<div className="sticky top-0 bg-dark-900 px-3 py-2 text-xs font-medium text-dark-400">
|
||||
{FILTER_GROUP_LABEL_KEYS[group] ? t(FILTER_GROUP_LABEL_KEYS[group]) : group}
|
||||
</div>
|
||||
{filters.map((filter) => (
|
||||
<button
|
||||
key={filter.key}
|
||||
onClick={() => handleFilterSelect(filter.key)}
|
||||
className={`flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-dark-700 ${
|
||||
target === filter.key ? 'bg-accent-500/20' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="text-dark-100">{filter.label}</span>
|
||||
{filter.count !== null && filter.count !== undefined && (
|
||||
<span className="text-xs text-dark-400">{filter.count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*,application/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-dark-600 bg-dark-800/50 p-6 text-dark-400 transition-colors hover:border-dark-500 hover:bg-dark-800 hover:text-dark-300"
|
||||
>
|
||||
<PhotoIcon />
|
||||
<span>{t('admin.broadcasts.addMedia')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Buttons selection */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.buttons')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{buttonsData?.buttons.map((button) => (
|
||||
<button
|
||||
key={button.key}
|
||||
onClick={() => toggleButton(button.key)}
|
||||
className={`rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||
selectedButtons.includes(button.key)
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'border border-dark-700 bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
{button.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Telegram-specific fields */}
|
||||
{channel === 'telegram' && (
|
||||
<>
|
||||
{/* Message text */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.messageText')}
|
||||
</label>
|
||||
<textarea
|
||||
value={messageText}
|
||||
onChange={(e) => setMessageText(e.target.value)}
|
||||
placeholder={t('admin.broadcasts.messageTextPlaceholder')}
|
||||
rows={6}
|
||||
maxLength={4000}
|
||||
className="input min-h-[150px] resize-y"
|
||||
/>
|
||||
<div className="mt-1 text-right text-xs text-dark-400">{messageText.length}/4000</div>
|
||||
</div>
|
||||
{/* Email section */}
|
||||
{emailEnabled && (
|
||||
<div className="card space-y-6">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.broadcasts.emailSection')}
|
||||
</h2>
|
||||
|
||||
{/* Media upload */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.media')}
|
||||
</label>
|
||||
{mediaFile ? (
|
||||
<div className="rounded-lg border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{mediaType === 'photo' && <PhotoIcon />}
|
||||
{mediaType === 'video' && <VideoIcon />}
|
||||
{mediaType === 'document' && <DocumentIcon />}
|
||||
<div>
|
||||
<p className="text-sm text-dark-100">{mediaFile.name}</p>
|
||||
<p className="text-xs text-dark-400">
|
||||
{(mediaFile.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemoveMedia}
|
||||
className="rounded-lg p-2 text-dark-400 hover:bg-dark-700 hover:text-error-400"
|
||||
disabled={isUploading}
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
{mediaPreview && (
|
||||
<img
|
||||
src={mediaPreview}
|
||||
alt="Preview"
|
||||
className="mt-3 max-h-48 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
{isUploading && (
|
||||
<div className="mt-2 flex items-center gap-2 text-sm text-accent-400">
|
||||
<RefreshIcon />
|
||||
{t('admin.broadcasts.uploading')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,video/*,application/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-dark-600 bg-dark-800/50 p-6 text-dark-400 transition-colors hover:border-dark-500 hover:bg-dark-800 hover:text-dark-300"
|
||||
>
|
||||
<PhotoIcon />
|
||||
<span>{t('admin.broadcasts.addMedia')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Email filter selection */}
|
||||
{renderFilterDropdown(
|
||||
'email',
|
||||
emailTarget,
|
||||
selectedEmailFilter,
|
||||
emailRecipientsCount,
|
||||
showEmailFilters,
|
||||
setShowEmailFilters,
|
||||
handleEmailFilterSelect,
|
||||
groupedEmailFilters,
|
||||
emailFiltersLoading,
|
||||
)}
|
||||
|
||||
{/* Buttons selection */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.buttons')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{buttonsData?.buttons.map((button) => (
|
||||
<button
|
||||
key={button.key}
|
||||
onClick={() => toggleButton(button.key)}
|
||||
className={`rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||
selectedButtons.includes(button.key)
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'border border-dark-700 bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
{button.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Email subject */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.emailSubject')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={emailSubject}
|
||||
onChange={(e) => setEmailSubject(e.target.value)}
|
||||
placeholder={t('admin.broadcasts.emailSubjectPlaceholder')}
|
||||
className="input"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email-specific fields */}
|
||||
{channel === 'email' && (
|
||||
<>
|
||||
{/* Email subject */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.emailSubject')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={emailSubject}
|
||||
onChange={(e) => setEmailSubject(e.target.value)}
|
||||
placeholder={t('admin.broadcasts.emailSubjectPlaceholder')}
|
||||
className="input"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
{/* Email content */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.emailContent')}
|
||||
</label>
|
||||
<p className="mb-2 text-xs text-dark-400">{t('admin.broadcasts.emailContentHint')}</p>
|
||||
<textarea
|
||||
value={emailContent}
|
||||
onChange={(e) => setEmailContent(e.target.value)}
|
||||
placeholder={t('admin.broadcasts.emailContentPlaceholder')}
|
||||
rows={10}
|
||||
className="input min-h-[200px] resize-y font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email content */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.emailContent')}
|
||||
</label>
|
||||
<p className="mb-2 text-xs text-dark-400">{t('admin.broadcasts.emailContentHint')}</p>
|
||||
<textarea
|
||||
value={emailContent}
|
||||
onChange={(e) => setEmailContent(e.target.value)}
|
||||
placeholder={t('admin.broadcasts.emailContentPlaceholder')}
|
||||
rows={10}
|
||||
className="input min-h-[200px] resize-y font-mono text-sm"
|
||||
/>
|
||||
{/* Email variables hint */}
|
||||
<div className="rounded-lg border border-dark-700 bg-dark-800/50 p-4">
|
||||
<p className="mb-2 text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.emailVariables')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['{{user_name}}', '{{email}}', '{{user_id}}'].map((variable) => (
|
||||
<code
|
||||
key={variable}
|
||||
className="rounded bg-dark-700 px-2 py-1 text-xs text-accent-400"
|
||||
>
|
||||
{variable}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Email variables hint */}
|
||||
<div className="rounded-lg border border-dark-700 bg-dark-800/50 p-4">
|
||||
<p className="mb-2 text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.emailVariables')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['{{user_name}}', '{{email}}', '{{user_id}}'].map((variable) => (
|
||||
<code
|
||||
key={variable}
|
||||
className="rounded bg-dark-700 px-2 py-1 text-xs text-accent-400"
|
||||
>
|
||||
{variable}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="card flex items-center justify-between">
|
||||
<div className="text-sm text-dark-400">
|
||||
{recipientsCount !== null && (
|
||||
{(telegramRecipientsCount !== null || emailRecipientsCount !== null) && (
|
||||
<span>
|
||||
{t('admin.broadcasts.willBeSent')}:{' '}
|
||||
<strong className="text-accent-400">{recipientsCount}</strong>{' '}
|
||||
{t('admin.broadcasts.users')}
|
||||
{telegramRecipientsCount !== null && (
|
||||
<>
|
||||
<strong className="text-accent-400">{telegramRecipientsCount}</strong> (TG)
|
||||
</>
|
||||
)}
|
||||
{telegramRecipientsCount !== null && emailRecipientsCount !== null && ' + '}
|
||||
{emailRecipientsCount !== null && (
|
||||
<>
|
||||
<strong className="text-accent-400">{emailRecipientsCount}</strong> (Email)
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -648,10 +738,10 @@ export default function AdminBroadcastCreate() {
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || createMutation.isPending || isUploading}
|
||||
disabled={!isValid || isPending || isUploading}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{createMutation.isPending ? <RefreshIcon /> : <BroadcastIcon />}
|
||||
{isPending ? <RefreshIcon /> : <BroadcastIcon />}
|
||||
{t('admin.broadcasts.send')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||
import type { PromoGroupSimple } from '../types';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
|
||||
|
||||
const BackIcon = () => (
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
@@ -19,21 +18,6 @@ const BackIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const METHOD_ICONS: Record<string, string> = {
|
||||
telegram_stars: '⭐',
|
||||
tribute: '🎁',
|
||||
cryptobot: '🪙',
|
||||
heleket: '⚡',
|
||||
yookassa: '🏦',
|
||||
mulenpay: '💳',
|
||||
pal24: '💸',
|
||||
platega: '💰',
|
||||
wata: '💧',
|
||||
freekassa: '💵',
|
||||
cloudpayments: '☁️',
|
||||
kassa_ai: '🏦',
|
||||
};
|
||||
|
||||
const METHOD_LABELS: Record<string, string> = {
|
||||
telegram_stars: 'Telegram Stars',
|
||||
tribute: 'Tribute',
|
||||
@@ -195,7 +179,6 @@ export default function AdminPaymentMethodEdit() {
|
||||
}
|
||||
|
||||
const displayName = config.display_name || config.default_display_name;
|
||||
const icon = METHOD_ICONS[config.method_id] || '💳';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -210,9 +193,6 @@ export default function AdminPaymentMethodEdit() {
|
||||
<BackIcon />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-700/50 text-xl">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-dark-50">{displayName}</h1>
|
||||
<p className="text-sm text-dark-500">
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||
import type { PaymentMethodConfig } from '../types';
|
||||
|
||||
// ============ Icons ============
|
||||
|
||||
const BackIcon = () => (
|
||||
@@ -64,23 +63,6 @@ const SaveIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============ Method icon by type ============
|
||||
|
||||
const METHOD_ICONS: Record<string, string> = {
|
||||
telegram_stars: '\u2B50',
|
||||
tribute: '\uD83C\uDF81',
|
||||
cryptobot: '\uD83E\uDE99',
|
||||
heleket: '\u26A1',
|
||||
yookassa: '\uD83C\uDFE6',
|
||||
mulenpay: '\uD83D\uDCB3',
|
||||
pal24: '\uD83D\uDCB8',
|
||||
platega: '\uD83D\uDCB0',
|
||||
wata: '\uD83D\uDCA7',
|
||||
freekassa: '\uD83D\uDCB5',
|
||||
cloudpayments: '\u2601\uFE0F',
|
||||
kassa_ai: '\uD83C\uDFE6',
|
||||
};
|
||||
|
||||
// ============ Sortable Card ============
|
||||
|
||||
interface SortableCardProps {
|
||||
@@ -102,7 +84,6 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
||||
};
|
||||
|
||||
const displayName = config.display_name || config.default_display_name;
|
||||
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3';
|
||||
|
||||
// Build condition summary chips
|
||||
const chips: string[] = [];
|
||||
@@ -152,11 +133,6 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
||||
<GripIcon />
|
||||
</button>
|
||||
|
||||
{/* Method icon */}
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-dark-700/50 text-xl">
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1 cursor-pointer" onClick={onClick}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -337,9 +313,6 @@ export default function AdminPaymentMethods() {
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
||||
<span className="text-3xl">{'\uD83D\uDCB3'}</span>
|
||||
</div>
|
||||
<div className="text-dark-400">{t('admin.paymentMethods.noMethods')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -434,19 +434,29 @@ export default function AdminTickets() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 text-sm text-dark-500">
|
||||
{t('admin.tickets.from')}: {formatUser(selectedTicket)}
|
||||
{selectedTicket.user?.telegram_id && (
|
||||
<div className="mb-4 flex items-center gap-2 text-sm text-dark-500">
|
||||
<span>
|
||||
{t('admin.tickets.from')}: {formatUser(selectedTicket)}
|
||||
{selectedTicket.user?.telegram_id && (
|
||||
<button
|
||||
onClick={() => copyToClipboard(String(selectedTicket.user!.telegram_id))}
|
||||
className="ml-1 rounded bg-dark-700 px-2 py-0.5 text-xs transition-colors hover:bg-dark-600"
|
||||
title={t('admin.tickets.copyTelegramId')}
|
||||
>
|
||||
TG: {selectedTicket.user!.telegram_id}
|
||||
</button>
|
||||
)}{' '}
|
||||
| {t('admin.tickets.created')}:{' '}
|
||||
{new Date(selectedTicket.created_at).toLocaleString()}
|
||||
</span>
|
||||
{selectedTicket.user && (
|
||||
<button
|
||||
onClick={() => copyToClipboard(String(selectedTicket.user!.telegram_id))}
|
||||
className="ml-1 rounded bg-dark-700 px-2 py-0.5 text-xs transition-colors hover:bg-dark-600"
|
||||
title={t('admin.tickets.copyTelegramId')}
|
||||
onClick={() => navigate(`/admin/users/${selectedTicket.user!.id}`)}
|
||||
className="shrink-0 rounded-lg border border-accent-500/30 bg-accent-500/10 px-2 py-0.5 text-xs text-accent-400 transition-colors hover:bg-accent-500/20"
|
||||
>
|
||||
TG: {selectedTicket.user!.telegram_id}
|
||||
{t('admin.tickets.viewUser')}
|
||||
</button>
|
||||
)}{' '}
|
||||
| {t('admin.tickets.created')}:{' '}
|
||||
{new Date(selectedTicket.created_at).toLocaleString()}
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['open', 'pending', 'answered', 'closed'].map((s) => (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { adminUsersApi, type UserListItem, type UsersStatsResponse } from '../api/adminUsers';
|
||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||
import { useNotify } from '../platform/hooks/useNotify';
|
||||
|
||||
// ============ Icons ============
|
||||
|
||||
@@ -58,204 +57,6 @@ const TelegramIcon = () => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
const DotsVerticalIcon = () => (
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 12.75a.75.75 0 110-1.5.75.75 0 010 1.5zM12 18.75a.75.75 0 110-1.5.75.75 0 010 1.5z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TrashIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ArrowPathIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const NoSymbolIcon = () => (
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ExclamationTriangleIcon = () => (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ============ Confirmation Modal ============
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText: string;
|
||||
confirmButtonClass?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function ConfirmationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
confirmButtonClass = 'bg-rose-500 hover:bg-rose-600',
|
||||
isLoading = false,
|
||||
}: ConfirmationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative w-full max-w-md rounded-2xl border border-dark-700 bg-dark-800 p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-rose-500/20 text-rose-400">
|
||||
<ExclamationTriangleIcon />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-dark-100">{title}</h3>
|
||||
</div>
|
||||
<p className="mb-6 text-dark-300">{message}</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 py-2.5 text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className={`flex-1 rounded-xl py-2.5 text-white transition-colors disabled:opacity-50 ${confirmButtonClass}`}
|
||||
>
|
||||
{isLoading ? t('common.loading') : confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ User Actions Menu ============
|
||||
|
||||
type UserAction = 'delete' | 'resetTrial' | 'resetSubscription' | 'disable';
|
||||
|
||||
interface UserActionsMenuProps {
|
||||
user: UserListItem;
|
||||
onAction: (action: UserAction, user: UserListItem) => void;
|
||||
}
|
||||
|
||||
function UserActionsMenu({ user, onAction }: UserActionsMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleAction = (action: UserAction) => {
|
||||
setIsOpen(false);
|
||||
onAction(action, user);
|
||||
};
|
||||
|
||||
const actions = [
|
||||
{
|
||||
key: 'resetTrial' as const,
|
||||
label: t('admin.users.userActions.resetTrial'),
|
||||
icon: <ArrowPathIcon />,
|
||||
className: 'text-blue-400 hover:bg-blue-500/10',
|
||||
},
|
||||
{
|
||||
key: 'resetSubscription' as const,
|
||||
label: t('admin.users.userActions.resetSubscription'),
|
||||
icon: <ArrowPathIcon />,
|
||||
className: 'text-amber-400 hover:bg-amber-500/10',
|
||||
},
|
||||
{
|
||||
key: 'disable' as const,
|
||||
label: t('admin.users.userActions.disable'),
|
||||
icon: <NoSymbolIcon />,
|
||||
className: 'text-dark-400 hover:bg-dark-700',
|
||||
},
|
||||
{
|
||||
key: 'delete' as const,
|
||||
label: t('admin.users.userActions.delete'),
|
||||
icon: <TrashIcon />,
|
||||
className: 'text-rose-400 hover:bg-rose-500/10',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<DotsVerticalIcon />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full z-50 mt-1 w-56 rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.key}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(action.key);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm transition-colors ${action.className}`}
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Components ============
|
||||
|
||||
interface StatCardProps {
|
||||
@@ -305,11 +106,10 @@ function StatusBadge({ status }: { status: string }) {
|
||||
interface UserRowProps {
|
||||
user: UserListItem;
|
||||
onClick: () => void;
|
||||
onAction: (action: UserAction, user: UserListItem) => void;
|
||||
formatAmount: (rubAmount: number) => string;
|
||||
}
|
||||
|
||||
function UserRow({ user, onClick, onAction, formatAmount }: UserRowProps) {
|
||||
function UserRow({ user, onClick, formatAmount }: UserRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
@@ -372,29 +172,17 @@ function UserRow({ user, onClick, onAction, formatAmount }: UserRowProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Menu - hide chevron on mobile, show only dots */}
|
||||
<UserActionsMenu user={user} onAction={onAction} />
|
||||
|
||||
<div className="hidden sm:block">
|
||||
<ChevronRightIcon />
|
||||
</div>
|
||||
<ChevronRightIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Main Page ============
|
||||
|
||||
interface ConfirmModalState {
|
||||
isOpen: boolean;
|
||||
action: UserAction | null;
|
||||
user: UserListItem | null;
|
||||
}
|
||||
|
||||
export default function AdminUsers() {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const navigate = useNavigate();
|
||||
const notify = useNotify();
|
||||
const { capabilities } = usePlatform();
|
||||
|
||||
const [users, setUsers] = useState<UserListItem[]>([]);
|
||||
@@ -406,12 +194,6 @@ export default function AdminUsers() {
|
||||
const [sortBy, setSortBy] = useState<string>('created_at');
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [confirmModal, setConfirmModal] = useState<ConfirmModalState>({
|
||||
isOpen: false,
|
||||
action: null,
|
||||
user: null,
|
||||
});
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const limit = 20;
|
||||
|
||||
@@ -458,92 +240,6 @@ export default function AdminUsers() {
|
||||
loadUsers();
|
||||
};
|
||||
|
||||
const handleUserAction = (action: UserAction, user: UserListItem) => {
|
||||
setConfirmModal({ isOpen: true, action, user });
|
||||
};
|
||||
|
||||
const closeConfirmModal = () => {
|
||||
setConfirmModal({ isOpen: false, action: null, user: null });
|
||||
};
|
||||
|
||||
const executeAction = async () => {
|
||||
const { action, user } = confirmModal;
|
||||
if (!action || !user) return;
|
||||
|
||||
setActionLoading(true);
|
||||
try {
|
||||
let result: { success: boolean; message: string };
|
||||
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
result = await adminUsersApi.deleteUser(user.id);
|
||||
break;
|
||||
case 'resetTrial':
|
||||
result = await adminUsersApi.resetTrial(user.id);
|
||||
break;
|
||||
case 'resetSubscription':
|
||||
result = await adminUsersApi.resetSubscription(user.id);
|
||||
break;
|
||||
case 'disable':
|
||||
result = await adminUsersApi.disableUser(user.id);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unknown action');
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
notify.success(t(`admin.users.userActions.success.${action}`), t('common.success'));
|
||||
loadUsers();
|
||||
loadStats();
|
||||
} else {
|
||||
notify.error(result.message || t('admin.users.userActions.error'), t('common.error'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Action failed:', error);
|
||||
notify.error(t('admin.users.userActions.error'), t('common.error'));
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
closeConfirmModal();
|
||||
}
|
||||
};
|
||||
|
||||
const getConfirmModalContent = () => {
|
||||
const { action } = confirmModal;
|
||||
if (!action) return { title: '', message: '', confirmText: '', confirmButtonClass: '' };
|
||||
|
||||
const configs: Record<
|
||||
UserAction,
|
||||
{ title: string; message: string; confirmText: string; confirmButtonClass: string }
|
||||
> = {
|
||||
delete: {
|
||||
title: t('admin.users.userActions.confirmDelete.title'),
|
||||
message: t('admin.users.userActions.confirmDelete.message'),
|
||||
confirmText: t('admin.users.userActions.delete'),
|
||||
confirmButtonClass: 'bg-rose-500 hover:bg-rose-600',
|
||||
},
|
||||
resetTrial: {
|
||||
title: t('admin.users.userActions.confirmResetTrial.title'),
|
||||
message: t('admin.users.userActions.confirmResetTrial.message'),
|
||||
confirmText: t('admin.users.userActions.resetTrial'),
|
||||
confirmButtonClass: 'bg-blue-500 hover:bg-blue-600',
|
||||
},
|
||||
resetSubscription: {
|
||||
title: t('admin.users.userActions.confirmResetSubscription.title'),
|
||||
message: t('admin.users.userActions.confirmResetSubscription.message'),
|
||||
confirmText: t('admin.users.userActions.resetSubscription'),
|
||||
confirmButtonClass: 'bg-amber-500 hover:bg-amber-600',
|
||||
},
|
||||
disable: {
|
||||
title: t('admin.users.userActions.confirmDisable.title'),
|
||||
message: t('admin.users.userActions.confirmDisable.message'),
|
||||
confirmText: t('admin.users.userActions.disable'),
|
||||
confirmButtonClass: 'bg-dark-600 hover:bg-dark-500',
|
||||
},
|
||||
};
|
||||
|
||||
return configs[action];
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const currentPage = Math.floor(offset / limit) + 1;
|
||||
|
||||
@@ -682,7 +378,6 @@ export default function AdminUsers() {
|
||||
key={user.id}
|
||||
user={user}
|
||||
onClick={() => navigate(`/admin/users/${user.id}`)}
|
||||
onAction={handleUserAction}
|
||||
formatAmount={(amount) => formatWithCurrency(amount)}
|
||||
/>
|
||||
))
|
||||
@@ -720,15 +415,6 @@ export default function AdminUsers() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User action confirmation modal */}
|
||||
<ConfirmationModal
|
||||
isOpen={confirmModal.isOpen}
|
||||
onClose={closeConfirmModal}
|
||||
onConfirm={executeAction}
|
||||
isLoading={actionLoading}
|
||||
{...getConfirmModalContent()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useBlockingStore } from '../store/blocking';
|
||||
import { subscriptionApi } from '../api/subscription';
|
||||
import { referralApi } from '../api/referral';
|
||||
import { balanceApi } from '../api/balance';
|
||||
@@ -54,6 +55,7 @@ export default function Dashboard() {
|
||||
const [trialError, setTrialError] = useState<string | null>(null);
|
||||
const { isCompleted: isOnboardingCompleted, complete: completeOnboarding } = useOnboarding();
|
||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||
const { blockingType } = useBlockingStore();
|
||||
|
||||
// Refresh user data on mount
|
||||
useEffect(() => {
|
||||
@@ -185,13 +187,14 @@ export default function Dashboard() {
|
||||
const hasNoSubscription = subscriptionResponse?.has_subscription === false && !subLoading;
|
||||
|
||||
// Show onboarding for new users after data loads
|
||||
// Skip if a blocking screen is active (channel subscription, maintenance, etc.)
|
||||
useEffect(() => {
|
||||
if (!isOnboardingCompleted && !subLoading && !refLoading) {
|
||||
if (!isOnboardingCompleted && !subLoading && !refLoading && !blockingType) {
|
||||
// Small delay to ensure DOM is ready
|
||||
const timer = setTimeout(() => setShowOnboarding(true), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOnboardingCompleted, subLoading, refLoading]);
|
||||
}, [isOnboardingCompleted, subLoading, refLoading, blockingType]);
|
||||
|
||||
// Define onboarding steps based on available data
|
||||
const onboardingSteps = useMemo(() => {
|
||||
|
||||
@@ -93,39 +93,47 @@ const sanitizeHtml = (html: string): string => {
|
||||
'blockquote',
|
||||
'code',
|
||||
'pre',
|
||||
's',
|
||||
'del',
|
||||
'ins',
|
||||
'span',
|
||||
'div',
|
||||
'tg-spoiler',
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'target', 'rel', 'class'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
});
|
||||
};
|
||||
|
||||
// Convert plain text to HTML with proper formatting
|
||||
// Convert content to formatted HTML (handles Telegram HTML + plain text)
|
||||
const formatContent = (content: string): string => {
|
||||
if (!content) return '';
|
||||
|
||||
// Check if content already has HTML tags
|
||||
const hasHtmlTags = /<[a-z][\s\S]*>/i.test(content);
|
||||
// Check if content has block-level HTML (full HTML document)
|
||||
const hasBlockHtml = /<(p|div|h[1-6]|ul|ol|blockquote)\b/i.test(content);
|
||||
|
||||
if (hasHtmlTags) {
|
||||
if (hasBlockHtml) {
|
||||
return sanitizeHtml(content);
|
||||
}
|
||||
|
||||
// Convert plain text to formatted HTML
|
||||
// Content may have inline Telegram HTML (<b>, <i>, <u>, <code>, <a>) but uses
|
||||
// newlines for structure. Convert newlines to paragraphs while preserving inline tags.
|
||||
const result = content
|
||||
.split(/\n\n+/) // Split by double newlines (paragraphs)
|
||||
.split(/\n\n+/)
|
||||
.map((paragraph) => {
|
||||
// Check if it's a header (starts with # or numeric like "1.")
|
||||
if (/^#{1,4}\s/.test(paragraph)) {
|
||||
const level = paragraph.match(/^(#{1,4})/)?.[1].length || 1;
|
||||
const text = paragraph.replace(/^#{1,4}\s*/, '');
|
||||
const trimmed = paragraph.trim();
|
||||
if (!trimmed) return '';
|
||||
|
||||
// Check if it's a markdown header
|
||||
if (/^#{1,4}\s/.test(trimmed)) {
|
||||
const level = trimmed.match(/^(#{1,4})/)?.[1].length || 1;
|
||||
const text = trimmed.replace(/^#{1,4}\s*/, '');
|
||||
return `<h${level}>${text}</h${level}>`;
|
||||
}
|
||||
|
||||
// Check for list items
|
||||
if (/^[-•]\s/.test(paragraph) || /^\d+[.)]\s/.test(paragraph)) {
|
||||
const lines = paragraph.split('\n');
|
||||
if (/^[-•]\s/.test(trimmed) || /^\d+[.)]\s/.test(trimmed)) {
|
||||
const lines = trimmed.split('\n');
|
||||
const isOrdered = /^\d+[.)]\s/.test(lines[0]);
|
||||
const listItems = lines
|
||||
.map((line) => line.replace(/^[-•]\s*/, '').replace(/^\d+[.)]\s*/, ''))
|
||||
@@ -135,11 +143,11 @@ const formatContent = (content: string): string => {
|
||||
return isOrdered ? `<ol>${listItems}</ol>` : `<ul>${listItems}</ul>`;
|
||||
}
|
||||
|
||||
// Regular paragraph - handle single line breaks within
|
||||
const formattedParagraph = paragraph.split('\n').join('<br/>');
|
||||
|
||||
return `<p>${formattedParagraph}</p>`;
|
||||
// Regular paragraph — single newlines become <br/>
|
||||
const formatted = trimmed.split('\n').join('<br/>');
|
||||
return `<p>${formatted}</p>`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('');
|
||||
|
||||
return sanitizeHtml(result);
|
||||
|
||||
@@ -18,6 +18,8 @@ import { getAndClearReturnUrl } from '../utils/token';
|
||||
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
|
||||
import LanguageSwitcher from '../components/LanguageSwitcher';
|
||||
import TelegramLoginButton from '../components/TelegramLoginButton';
|
||||
import OAuthProviderIcon from '../components/OAuthProviderIcon';
|
||||
import { saveOAuthState } from './OAuthCallback';
|
||||
|
||||
export default function Login() {
|
||||
const { t } = useTranslation();
|
||||
@@ -92,6 +94,29 @@ export default function Login() {
|
||||
});
|
||||
const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true;
|
||||
|
||||
// Fetch enabled OAuth providers
|
||||
const { data: oauthData } = useQuery({
|
||||
queryKey: ['oauth-providers'],
|
||||
queryFn: authApi.getOAuthProviders,
|
||||
staleTime: 60000,
|
||||
});
|
||||
const oauthProviders = oauthData?.providers ?? [];
|
||||
|
||||
const [oauthLoading, setOauthLoading] = useState<string | null>(null);
|
||||
|
||||
const handleOAuthLogin = async (provider: string) => {
|
||||
setError('');
|
||||
setOauthLoading(provider);
|
||||
try {
|
||||
const { authorize_url, state } = await authApi.getOAuthAuthorizeUrl(provider);
|
||||
saveOAuthState(state, provider);
|
||||
window.location.href = authorize_url;
|
||||
} catch {
|
||||
setError(t('auth.oauthError', 'Authorization was denied or failed'));
|
||||
setOauthLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
|
||||
|
||||
// If email auth is disabled but user came with ref param, redirect to bot
|
||||
@@ -424,6 +449,38 @@ export default function Login() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OAuth providers section */}
|
||||
{oauthProviders.length > 0 && (
|
||||
<>
|
||||
<div className="my-6 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-dark-700" />
|
||||
<span className="text-xs text-dark-500">{t('auth.or', 'or')}</span>
|
||||
<div className="h-px flex-1 bg-dark-700" />
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{oauthProviders.map((provider) => (
|
||||
<button
|
||||
key={provider.name}
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin(provider.name)}
|
||||
disabled={oauthLoading !== null}
|
||||
className="hover:bg-dark-750 flex w-full items-center justify-center gap-3 rounded-xl border border-dark-700 bg-dark-800 px-4 py-3 text-sm font-medium text-dark-100 transition-colors hover:border-dark-600 disabled:opacity-50"
|
||||
>
|
||||
{oauthLoading === provider.name ? (
|
||||
<span className="h-5 w-5 animate-spin rounded-full border-2 border-dark-400 border-t-white" />
|
||||
) : (
|
||||
<OAuthProviderIcon provider={provider.name} className="h-5 w-5" />
|
||||
)}
|
||||
{t(
|
||||
`auth.continueWith${provider.name.charAt(0).toUpperCase() + provider.name.slice(1)}`,
|
||||
`Continue with ${provider.display_name}`,
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Email auth section - only when enabled */}
|
||||
{isEmailAuthEnabled && (
|
||||
<>
|
||||
|
||||
119
src/pages/OAuthCallback.tsx
Normal file
119
src/pages/OAuthCallback.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
// SessionStorage helpers for OAuth state
|
||||
const OAUTH_STATE_KEY = 'oauth_state';
|
||||
const OAUTH_PROVIDER_KEY = 'oauth_provider';
|
||||
|
||||
export function saveOAuthState(state: string, provider: string): void {
|
||||
sessionStorage.setItem(OAUTH_STATE_KEY, state);
|
||||
sessionStorage.setItem(OAUTH_PROVIDER_KEY, provider);
|
||||
}
|
||||
|
||||
export function getAndClearOAuthState(): { state: string; provider: string } | null {
|
||||
const state = sessionStorage.getItem(OAUTH_STATE_KEY);
|
||||
const provider = sessionStorage.getItem(OAUTH_PROVIDER_KEY);
|
||||
sessionStorage.removeItem(OAUTH_STATE_KEY);
|
||||
sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
|
||||
if (!state || !provider) return null;
|
||||
return { state, provider };
|
||||
}
|
||||
|
||||
export default function OAuthCallback() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [error, setError] = useState('');
|
||||
const { loginWithOAuth, isAuthenticated } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const authenticate = async () => {
|
||||
const code = searchParams.get('code');
|
||||
const urlState = searchParams.get('state');
|
||||
|
||||
if (!code || !urlState) {
|
||||
setError(t('auth.oauthError', 'Authorization was denied or failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get saved state from sessionStorage
|
||||
const saved = getAndClearOAuthState();
|
||||
if (!saved) {
|
||||
setError(t('auth.oauthExpired', 'OAuth session expired. Please try again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate state match
|
||||
if (saved.state !== urlState) {
|
||||
setError(t('auth.oauthError', 'Authorization was denied or failed'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await loginWithOAuth(saved.provider, code, urlState);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const error = err as { response?: { data?: { detail?: string } } };
|
||||
setError(
|
||||
error.response?.data?.detail ||
|
||||
t('auth.oauthError', 'Authorization was denied or failed'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
authenticate();
|
||||
}, [searchParams, loginWithOAuth, navigate, isAuthenticated, t]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center px-4 py-8">
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
|
||||
<div className="relative w-full max-w-md text-center">
|
||||
<div className="card">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-error-500/20">
|
||||
<svg
|
||||
className="h-8 w-8 text-error-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="mb-2 text-lg font-semibold text-dark-50">{t('auth.loginFailed')}</h2>
|
||||
<p className="mb-6 text-sm text-dark-400">{error}</p>
|
||||
<button
|
||||
onClick={() => navigate('/login', { replace: true })}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
{t('auth.backToLogin', 'Back to login')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-dark-950 via-dark-900 to-dark-950" />
|
||||
<div className="relative text-center">
|
||||
<div className="mx-auto mb-4 h-10 w-10 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
<h2 className="text-lg font-semibold text-dark-50">{t('auth.authenticating')}</h2>
|
||||
<p className="mt-2 text-sm text-dark-400">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { balanceApi } from '../api/balance';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { Card } from '@/components/data-display/Card';
|
||||
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||
import PaymentMethodIcon from '@/components/PaymentMethodIcon';
|
||||
|
||||
export default function TopUpMethodSelect() {
|
||||
const { t } = useTranslation();
|
||||
@@ -70,8 +71,11 @@ export default function TopUpMethodSelect() {
|
||||
className={!method.is_available ? 'cursor-not-allowed opacity-50' : ''}
|
||||
onClick={() => method.is_available && handleMethodClick(method.id)}
|
||||
>
|
||||
<div className="font-semibold text-dark-100">
|
||||
{translatedName || method.name}
|
||||
<div className="flex items-center gap-3">
|
||||
<PaymentMethodIcon method={methodKey} className="h-8 w-8 flex-shrink-0" />
|
||||
<div className="font-semibold text-dark-100">
|
||||
{translatedName || method.name}
|
||||
</div>
|
||||
</div>
|
||||
{(translatedDesc || method.description) && (
|
||||
<div className="mt-1 text-sm text-dark-500">
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel';
|
||||
import FortuneWheel from '../components/wheel/FortuneWheel';
|
||||
import WheelLegend from '../components/wheel/WheelLegend';
|
||||
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
||||
import { usePlatform, useHaptic } from '@/platform';
|
||||
import { useNotify } from '@/platform/hooks/useNotify';
|
||||
import { Card } from '@/components/data-display/Card/Card';
|
||||
@@ -461,11 +460,13 @@ export default function Wheel() {
|
||||
const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days;
|
||||
const bothMethodsAvailable = !!(starsEnabled && daysEnabled);
|
||||
|
||||
// Stars via Telegram invoice don't require ruble balance, so only check daily limit
|
||||
const dailyLimitReached = config.daily_limit > 0 && config.user_spins_today >= config.daily_limit;
|
||||
const spinDisabled =
|
||||
!config.can_spin ||
|
||||
isSpinning ||
|
||||
isPayingStars ||
|
||||
(config.daily_limit > 0 && config.user_spins_today >= config.daily_limit);
|
||||
dailyLimitReached ||
|
||||
(paymentType === 'telegram_stars' ? !starsEnabled : !config.can_spin);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-6 pb-8">
|
||||
@@ -570,25 +571,21 @@ export default function Wheel() {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Cannot spin hint */}
|
||||
{!config.can_spin && !isSpinning && (
|
||||
<>
|
||||
{config.can_spin_reason === 'daily_limit_reached' ? (
|
||||
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
|
||||
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
|
||||
</div>
|
||||
) : config.can_spin_reason === 'insufficient_balance' ? (
|
||||
<InsufficientBalancePrompt
|
||||
missingAmountKopeks={
|
||||
config.required_balance_kopeks - config.user_balance_kopeks
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
|
||||
<p className="text-dark-400">{t('wheel.errors.cannotSpin')}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{/* Cannot spin hint — only show for days payment (Stars via invoice always works) */}
|
||||
{!isSpinning && paymentType !== 'telegram_stars' && !config.can_spin && (
|
||||
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
|
||||
<p className="text-dark-400">
|
||||
{config.can_spin_reason === 'daily_limit_reached'
|
||||
? t('wheel.errors.dailyLimitReached')
|
||||
: t('wheel.errors.cannotSpin')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Daily limit hint for Stars payment (not covered by can_spin check) */}
|
||||
{!isSpinning && paymentType === 'telegram_stars' && dailyLimitReached && (
|
||||
<div className="rounded-linear border border-dark-700/30 bg-dark-800/30 p-4 text-center">
|
||||
<p className="text-dark-400">{t('wheel.errors.dailyLimitReached')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline Result Card */}
|
||||
|
||||
@@ -33,6 +33,7 @@ interface AuthState {
|
||||
loginWithTelegram: (initData: string) => Promise<void>;
|
||||
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise<void>;
|
||||
loginWithEmail: (email: string, password: string) => Promise<void>;
|
||||
loginWithOAuth: (provider: string, code: string, state: string) => Promise<void>;
|
||||
registerWithEmail: (
|
||||
email: string,
|
||||
password: string,
|
||||
@@ -267,6 +268,18 @@ export const useAuthStore = create<AuthState>()(
|
||||
await get().checkAdminStatus();
|
||||
},
|
||||
|
||||
loginWithOAuth: async (provider, code, state) => {
|
||||
const response = await authApi.oauthCallback(provider, code, state);
|
||||
tokenStorage.setTokens(response.access_token, response.refresh_token);
|
||||
set({
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
user: response.user,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
await get().checkAdminStatus();
|
||||
},
|
||||
|
||||
registerWithEmail: async (email, password, firstName, referralCode) => {
|
||||
// Registration now returns message, not tokens
|
||||
// User must verify email before they can login
|
||||
|
||||
@@ -270,6 +270,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Twemoji — cross-platform emoji rendering */
|
||||
img.twemoji {
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
vertical-align: -0.2em;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ========== BENTO DESIGN SYSTEM ========== */
|
||||
|
||||
@@ -845,6 +853,19 @@
|
||||
@apply italic text-dark-300;
|
||||
}
|
||||
|
||||
.prose u {
|
||||
@apply underline underline-offset-2;
|
||||
}
|
||||
|
||||
.prose s,
|
||||
.prose del {
|
||||
@apply text-dark-400 line-through;
|
||||
}
|
||||
|
||||
.prose tg-spoiler {
|
||||
@apply cursor-pointer rounded bg-dark-600 px-1 text-transparent transition-colors hover:bg-transparent hover:text-dark-200;
|
||||
}
|
||||
|
||||
.prose blockquote {
|
||||
@apply my-4 rounded-r-lg border-l-4 border-accent-500/50 bg-dark-800/30 py-2 pl-4 italic text-dark-300;
|
||||
}
|
||||
@@ -921,6 +942,15 @@
|
||||
@apply text-champagne-700;
|
||||
}
|
||||
|
||||
.light .prose s,
|
||||
.light .prose del {
|
||||
@apply text-champagne-500;
|
||||
}
|
||||
|
||||
.light .prose tg-spoiler {
|
||||
@apply bg-champagne-300 text-transparent hover:bg-transparent hover:text-champagne-800;
|
||||
}
|
||||
|
||||
.light .prose blockquote {
|
||||
@apply border-champagne-400 bg-champagne-100/50 text-champagne-700;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,13 @@ export interface User {
|
||||
referral_code: string | null;
|
||||
language: string;
|
||||
created_at: string;
|
||||
auth_type: 'telegram' | 'email'; // Тип аутентификации
|
||||
auth_type: 'telegram' | 'email' | 'google' | 'yandex' | 'discord' | 'vk'; // Тип аутентификации
|
||||
}
|
||||
|
||||
// OAuth types
|
||||
export interface OAuthProvider {
|
||||
name: string;
|
||||
display_name: string;
|
||||
}
|
||||
|
||||
// Auth types
|
||||
|
||||
Reference in New Issue
Block a user