mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
@@ -22,7 +22,9 @@ services:
|
|||||||
# Внутри контейнера nginx слушает на порту 80
|
# Внутри контейнера nginx слушает на порту 80
|
||||||
- '${CABINET_PORT:-3020}:80'
|
- '${CABINET_PORT:-3020}:80'
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:80/']
|
# 127.0.0.1 вместо localhost: localhost может резолвиться в [::1], и на
|
||||||
|
# образах, где nginx слушает только IPv4, healthcheck ложно падает
|
||||||
|
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://127.0.0.1:80/']
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
# Слушаем и IPv6: healthcheck ходит на localhost, который в контейнере
|
||||||
|
# резолвится в [::1] — без этой строки wget получает Connection refused
|
||||||
|
# и контейнер стабильно уходит в unhealthy (#3024 в репозитории бота).
|
||||||
|
listen [::]:80;
|
||||||
server_name _;
|
server_name _;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|||||||
380
package-lock.json
generated
380
package-lock.json
generated
@@ -78,7 +78,8 @@
|
|||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
@@ -3373,6 +3374,17 @@
|
|||||||
"@babel/types": "^7.28.2"
|
"@babel/types": "^7.28.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/chai": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/deep-eql": "*",
|
||||||
|
"assertion-error": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/d3-array": {
|
"node_modules/@types/d3-array": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
@@ -3436,6 +3448,13 @@
|
|||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/deep-eql": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/dompurify": {
|
"node_modules/@types/dompurify": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
|
||||||
@@ -3547,6 +3566,119 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vitest/expect": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.1.0",
|
||||||
|
"@types/chai": "^5.2.2",
|
||||||
|
"@vitest/spy": "4.1.10",
|
||||||
|
"@vitest/utils": "4.1.10",
|
||||||
|
"chai": "^6.2.2",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/mocker": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/spy": "4.1.10",
|
||||||
|
"estree-walker": "^3.0.3",
|
||||||
|
"magic-string": "^0.30.21"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"msw": "^2.4.9",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"msw": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/pretty-format": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/runner": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/utils": "4.1.10",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/snapshot": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.10",
|
||||||
|
"@vitest/utils": "4.1.10",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"pathe": "^2.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/spy": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vitest/utils": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/pretty-format": "4.1.10",
|
||||||
|
"convert-source-map": "^2.0.0",
|
||||||
|
"tinyrainbow": "^3.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
@@ -3634,6 +3766,16 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/assertion-error": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/asynckit": {
|
"node_modules/asynckit": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||||
@@ -3808,6 +3950,16 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/chai": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@@ -4267,6 +4419,13 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-module-lexer": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
@@ -4367,6 +4526,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eventemitter3": {
|
"node_modules/eventemitter3": {
|
||||||
"version": "5.0.4",
|
"version": "5.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
@@ -4382,6 +4551,16 @@
|
|||||||
"node": ">=0.8.x"
|
"node": ">=0.8.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expect-type": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-equals": {
|
"node_modules/fast-equals": {
|
||||||
"version": "5.4.0",
|
"version": "5.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||||
@@ -5089,6 +5268,16 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/markdown-it": {
|
"node_modules/markdown-it": {
|
||||||
"version": "14.1.1",
|
"version": "14.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||||
@@ -5285,6 +5474,20 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/obug": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/sxzz",
|
||||||
|
"https://opencollective.com/debug"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/onetime": {
|
"node_modules/onetime": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
|
||||||
@@ -5314,6 +5517,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pathe": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -6228,6 +6438,13 @@
|
|||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/siginfo": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/sigma": {
|
"node_modules/sigma": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz",
|
||||||
@@ -6296,6 +6513,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/stackback": {
|
||||||
|
"version": "0.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/std-env": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/string-argv": {
|
"node_modules/string-argv": {
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
|
||||||
@@ -6462,6 +6693,23 @@
|
|||||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/tinybench": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tinyexec": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -6510,6 +6758,16 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyrainbow": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -6803,6 +7061,109 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vitest": {
|
||||||
|
"version": "4.1.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
|
||||||
|
"integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@vitest/expect": "4.1.10",
|
||||||
|
"@vitest/mocker": "4.1.10",
|
||||||
|
"@vitest/pretty-format": "4.1.10",
|
||||||
|
"@vitest/runner": "4.1.10",
|
||||||
|
"@vitest/snapshot": "4.1.10",
|
||||||
|
"@vitest/spy": "4.1.10",
|
||||||
|
"@vitest/utils": "4.1.10",
|
||||||
|
"es-module-lexer": "^2.0.0",
|
||||||
|
"expect-type": "^1.3.0",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"obug": "^2.1.1",
|
||||||
|
"pathe": "^2.0.3",
|
||||||
|
"picomatch": "^4.0.3",
|
||||||
|
"std-env": "^4.0.0-rc.1",
|
||||||
|
"tinybench": "^2.9.0",
|
||||||
|
"tinyexec": "^1.0.2",
|
||||||
|
"tinyglobby": "^0.2.15",
|
||||||
|
"tinyrainbow": "^3.1.0",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
|
"why-is-node-running": "^2.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vitest": "vitest.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/vitest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@edge-runtime/vm": "*",
|
||||||
|
"@opentelemetry/api": "^1.9.0",
|
||||||
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
|
"@vitest/browser-playwright": "4.1.10",
|
||||||
|
"@vitest/browser-preview": "4.1.10",
|
||||||
|
"@vitest/browser-webdriverio": "4.1.10",
|
||||||
|
"@vitest/coverage-istanbul": "4.1.10",
|
||||||
|
"@vitest/coverage-v8": "4.1.10",
|
||||||
|
"@vitest/ui": "4.1.10",
|
||||||
|
"happy-dom": "*",
|
||||||
|
"jsdom": "*",
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@edge-runtime/vm": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@opentelemetry/api": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-playwright": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-preview": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/browser-webdriverio": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-istanbul": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/coverage-v8": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@vitest/ui": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"happy-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jsdom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vitest/node_modules/picomatch": {
|
||||||
|
"version": "4.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||||
|
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/void-elements": {
|
"node_modules/void-elements": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||||
@@ -6818,6 +7179,23 @@
|
|||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/why-is-node-running": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"siginfo": "^2.0.0",
|
||||||
|
"stackback": "0.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"why-is-node-running": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "9.0.2",
|
"version": "9.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
"format": "biome format --write .",
|
"format": "biome format --write .",
|
||||||
"format:check": "biome format .",
|
"format:check": "biome format .",
|
||||||
"check": "biome check .",
|
"check": "biome check .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
@@ -87,7 +89,8 @@
|
|||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.2.2",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx,json}": [
|
"*.{ts,tsx,json}": [
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
ServiceUnavailableScreen,
|
ServiceUnavailableScreen,
|
||||||
} from './components/blocking';
|
} from './components/blocking';
|
||||||
import { ErrorBoundary } from './components/ErrorBoundary';
|
import { ErrorBoundary } from './components/ErrorBoundary';
|
||||||
|
import { BackgroundHost } from './components/backgrounds/BackgroundHost';
|
||||||
import { PermissionRoute } from '@/components/auth/PermissionRoute';
|
import { PermissionRoute } from '@/components/auth/PermissionRoute';
|
||||||
import { saveReturnUrl } from './utils/token';
|
import { saveReturnUrl } from './utils/token';
|
||||||
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
|
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
|
||||||
@@ -259,6 +260,8 @@ function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Живёт над <Routes>: анимация фона не перезапускается при навигации */}
|
||||||
|
<BackgroundHost />
|
||||||
<BlockingOverlay />
|
<BlockingOverlay />
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public routes */}
|
{/* Public routes */}
|
||||||
|
|||||||
@@ -173,18 +173,27 @@ function TelegramBackButton() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `admin_ticket_<id>` startapp param → /admin/tickets/<id>. */
|
/** Supported startapp params → in-app destinations. */
|
||||||
const ADMIN_TICKET_START_PARAM_RE = /^admin_ticket_(\d+)$/;
|
const START_PARAM_ROUTES: Array<{ re: RegExp; to: (match: RegExpExecArray) => string }> = [
|
||||||
|
// Admin ticket notification buttons in group chats (bot issue #2988).
|
||||||
|
{ re: /^admin_ticket_(\d+)$/, to: (match) => `/admin/tickets/${match[1]}` },
|
||||||
|
// «Продлить» links for expired subscriptions in the bot's rich main menu.
|
||||||
|
{ re: /^renew_(\d+)$/, to: (match) => `/subscriptions/${match[1]}/renew` },
|
||||||
|
{ re: /^subscriptions$/, to: () => '/subscriptions' },
|
||||||
|
// Paid-trial «Активировать триал» link in the bot's rich main menu — the
|
||||||
|
// dashboard renders TrialOfferCard with the pay-and-activate flow.
|
||||||
|
{ re: /^trial$/, to: () => '/' },
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Routes a Telegram Mini App start param to an in-app destination on launch.
|
* Routes a Telegram Mini App start param to an in-app destination on launch.
|
||||||
*
|
*
|
||||||
* Admin ticket notification buttons in GROUP/channel chats open the cabinet via
|
* Text links and buttons outside private-chat web_app buttons can only enter the
|
||||||
* a `t.me/<bot>/<app>?startapp=admin_ticket_<id>` deep link (bot issue #2988) —
|
* Mini App via `t.me/<bot>/<app>?startapp=<param>` deep links: admin ticket
|
||||||
* `web_app` buttons don't work in group chats, so the startapp param is the only
|
* notifications in GROUP/channel chats (bot issue #2988) and the bot's rich
|
||||||
* way in. Telegram delivers it as `tgWebAppStartParam`; we map it to the admin
|
* main-menu «Продлить» links for expired subscriptions. Telegram delivers the
|
||||||
* ticket route once on mount. Access is still gated by the route's
|
* param as `tgWebAppStartParam`; we map it to a route once on mount. Access is
|
||||||
* `PermissionRoute permission="tickets:read"`.
|
* still gated by each route's own guards (e.g. `PermissionRoute`).
|
||||||
*/
|
*/
|
||||||
function StartParamNavigator() {
|
function StartParamNavigator() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -202,9 +211,12 @@ function StartParamNavigator() {
|
|||||||
}
|
}
|
||||||
if (!startParam) return;
|
if (!startParam) return;
|
||||||
|
|
||||||
const match = ADMIN_TICKET_START_PARAM_RE.exec(startParam);
|
for (const { re, to } of START_PARAM_ROUTES) {
|
||||||
if (match) {
|
const match = re.exec(startParam);
|
||||||
navigate(`/admin/tickets/${match[1]}`, { replace: true });
|
if (match) {
|
||||||
|
navigate(to(match), { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export interface CustomBroadcastButton {
|
|||||||
label: string;
|
label: string;
|
||||||
action_type: 'callback' | 'url';
|
action_type: 'callback' | 'url';
|
||||||
action_value: string;
|
action_value: string;
|
||||||
|
/** Telegram custom emoji перед текстом кнопки (числовая строка custom_emoji_id) */
|
||||||
|
icon_custom_emoji_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BroadcastMedia {
|
export interface BroadcastMedia {
|
||||||
|
|||||||
@@ -173,6 +173,23 @@ export interface SubscriptionRequestHistory {
|
|||||||
records: SubscriptionRequestRecord[];
|
records: SubscriptionRequestRecord[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserActivityItem {
|
||||||
|
type: string;
|
||||||
|
subtype: string | null;
|
||||||
|
source: string | null;
|
||||||
|
title: string | null;
|
||||||
|
amount_kopeks: number | null;
|
||||||
|
timestamp: string;
|
||||||
|
meta: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserActivityResponse {
|
||||||
|
items: UserActivityItem[];
|
||||||
|
total: number;
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UserNodeUsageItem {
|
export interface UserNodeUsageItem {
|
||||||
node_uuid: string;
|
node_uuid: string;
|
||||||
node_name: string;
|
node_name: string;
|
||||||
@@ -699,6 +716,19 @@ export const adminUsersApi = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Unified activity timeline (bot + cabinet actions)
|
||||||
|
getUserActivity: async (
|
||||||
|
userId: number,
|
||||||
|
offset = 0,
|
||||||
|
limit = 25,
|
||||||
|
types?: string,
|
||||||
|
): Promise<UserActivityResponse> => {
|
||||||
|
const params: Record<string, unknown> = { offset, limit };
|
||||||
|
if (types) params.types = types;
|
||||||
|
const response = await apiClient.get(`/cabinet/admin/users/${userId}/activity`, { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Get subscription request history from Remnawave panel
|
// Get subscription request history from Remnawave panel
|
||||||
getSubscriptionRequestHistory: async (
|
getSubscriptionRequestHistory: async (
|
||||||
userId: number,
|
userId: number,
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { infoApi, type LanguageInfo } from '@/api/info';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { infoApi } from '@/api/info';
|
||||||
import { ChevronDownIcon } from '@/components/icons';
|
import { ChevronDownIcon } from '@/components/icons';
|
||||||
|
|
||||||
export default function LanguageSwitcher() {
|
export default function LanguageSwitcher() {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [availableLanguages, setAvailableLanguages] = useState<LanguageInfo[]>([]);
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
// Кэш react-query переживает перемонтирование AppShell при смене роута:
|
||||||
const fetchLanguages = async () => {
|
// локальный useState начинал каждый маунт с пустого списка, и до ответа API
|
||||||
try {
|
// компонент рендерил null — переключатель «мигал» при каждой навигации.
|
||||||
const data = await infoApi.getLanguages();
|
const { data } = useQuery({
|
||||||
setAvailableLanguages(data.languages);
|
queryKey: ['languages'],
|
||||||
} catch {
|
queryFn: infoApi.getLanguages,
|
||||||
// Silently fall back to empty list — component handles it gracefully
|
staleTime: 1000 * 60 * 5,
|
||||||
}
|
});
|
||||||
};
|
const availableLanguages = data?.languages ?? [];
|
||||||
fetchLanguages();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const currentLang = availableLanguages.find((l) => l.code === i18n.language) ||
|
const currentLang = availableLanguages.find((l) => l.code === i18n.language) ||
|
||||||
availableLanguages[0] || { code: 'ru', name: 'RU', flag: '🇷🇺' };
|
availableLanguages[0] || { code: 'ru', name: 'RU', flag: '🇷🇺' };
|
||||||
|
|||||||
269
src/components/admin/userDetail/ActivityTab.tsx
Normal file
269
src/components/admin/userDetail/ActivityTab.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { adminUsersApi, type UserActivityItem } from '../../../api/adminUsers';
|
||||||
|
import {
|
||||||
|
BanknotesIcon,
|
||||||
|
BoltIcon,
|
||||||
|
BotIcon,
|
||||||
|
CabinetIcon,
|
||||||
|
ChartBarIcon,
|
||||||
|
ChatIcon,
|
||||||
|
GiftIcon,
|
||||||
|
HistoryIcon,
|
||||||
|
PulseIcon,
|
||||||
|
TagIcon,
|
||||||
|
TicketIcon,
|
||||||
|
UsersIcon,
|
||||||
|
WalletIcon,
|
||||||
|
WheelIcon,
|
||||||
|
} from '@/components/icons';
|
||||||
|
import { StatCard } from '@/components/stats';
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Activity tab — unified timeline of the user's actions in the bot
|
||||||
|
// and the cabinet (GET /cabinet/admin/users/{id}/activity).
|
||||||
|
// Self-contained: owns filter/paging state, parent only passes
|
||||||
|
// userId + formatDate (the house convention, see TicketsTab).
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ActivityTabProps {
|
||||||
|
userId: number;
|
||||||
|
formatDate: (date: string | null) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
/** Filter chips → backend `types` CSV (null = no filter). */
|
||||||
|
const FILTERS: Array<{ key: string; types: string | null }> = [
|
||||||
|
{ key: 'all', types: null },
|
||||||
|
{ key: 'payments', types: 'transaction,withdrawal' },
|
||||||
|
{ key: 'events', types: 'event' },
|
||||||
|
{ key: 'promo', types: 'promocode,coupon,wheel_spin,poll' },
|
||||||
|
{ key: 'tickets', types: 'ticket' },
|
||||||
|
{ key: 'gifts', types: 'gift_sent,gift_received' },
|
||||||
|
{ key: 'referrals', types: 'referral_earning' },
|
||||||
|
{ key: 'clicks', types: 'button_click,cabinet_action' },
|
||||||
|
{ key: 'logins', types: 'cabinet_login' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TYPE_VISUALS: Record<string, { icon: typeof WalletIcon; tint: string }> = {
|
||||||
|
transaction: { icon: WalletIcon, tint: 'bg-accent-500/15 text-accent-400' },
|
||||||
|
event: { icon: PulseIcon, tint: 'bg-success-500/15 text-success-400' },
|
||||||
|
promocode: { icon: TagIcon, tint: 'bg-warning-500/15 text-warning-400' },
|
||||||
|
coupon: { icon: TicketIcon, tint: 'bg-warning-500/15 text-warning-400' },
|
||||||
|
ticket: { icon: ChatIcon, tint: 'bg-error-500/15 text-error-400' },
|
||||||
|
wheel_spin: { icon: WheelIcon, tint: 'bg-accent-500/15 text-accent-400' },
|
||||||
|
poll: { icon: ChartBarIcon, tint: 'bg-success-500/15 text-success-400' },
|
||||||
|
gift_sent: { icon: GiftIcon, tint: 'bg-warning-500/15 text-warning-400' },
|
||||||
|
gift_received: { icon: GiftIcon, tint: 'bg-success-500/15 text-success-400' },
|
||||||
|
referral_earning: { icon: UsersIcon, tint: 'bg-success-500/15 text-success-400' },
|
||||||
|
cabinet_login: { icon: CabinetIcon, tint: 'bg-dark-700/60 text-dark-300' },
|
||||||
|
withdrawal: { icon: BanknotesIcon, tint: 'bg-error-500/15 text-error-400' },
|
||||||
|
button_click: { icon: BotIcon, tint: 'bg-dark-700/60 text-dark-300' },
|
||||||
|
cabinet_action: { icon: BoltIcon, tint: 'bg-dark-700/60 text-dark-300' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_VISUAL = { icon: HistoryIcon, tint: 'bg-dark-700/60 text-dark-300' };
|
||||||
|
|
||||||
|
/** Transaction subtypes displayed as an expense (red, minus sign). */
|
||||||
|
const EXPENSE_SUBTYPES = new Set(['withdrawal', 'subscription_payment', 'gift_payment']);
|
||||||
|
|
||||||
|
function formatRelativeTime(
|
||||||
|
dateString: string,
|
||||||
|
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||||
|
): string {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const diffSec = Math.floor((Date.now() - date.getTime()) / 1000);
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
const diffHour = Math.floor(diffMin / 60);
|
||||||
|
const diffDay = Math.floor(diffHour / 24);
|
||||||
|
|
||||||
|
if (diffSec < 60) return t('admin.auditLog.time.justNow');
|
||||||
|
if (diffMin < 60) return t('admin.auditLog.time.minutesAgo', { count: diffMin });
|
||||||
|
if (diffHour < 24) return t('admin.auditLog.time.hoursAgo', { count: diffHour });
|
||||||
|
if (diffDay < 30) return t('admin.auditLog.time.daysAgo', { count: diffDay });
|
||||||
|
return date.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubtypeBadge({ subtype }: { subtype: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const label =
|
||||||
|
t(`admin.users.detail.activity.subtypes.${subtype}`, { defaultValue: '' }) || subtype;
|
||||||
|
return (
|
||||||
|
<span className="rounded-full border border-dark-600/60 bg-dark-700/40 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-dark-300">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AmountChip({ item }: { item: UserActivityItem }) {
|
||||||
|
if (item.amount_kopeks == null || item.amount_kopeks === 0) return null;
|
||||||
|
|
||||||
|
const rubles = Math.abs(item.amount_kopeks) / 100;
|
||||||
|
const isExpense =
|
||||||
|
item.type === 'withdrawal' ||
|
||||||
|
(item.type === 'transaction' && item.subtype != null && EXPENSE_SUBTYPES.has(item.subtype)) ||
|
||||||
|
item.type === 'gift_sent';
|
||||||
|
const sign = isExpense ? '−' : '+';
|
||||||
|
const tone = isExpense ? 'text-error-400' : 'text-success-400';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`shrink-0 text-sm font-semibold tabular-nums ${tone}`}>
|
||||||
|
{sign}
|
||||||
|
{rubles.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} ₽
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActivityTab({ userId, formatDate }: ActivityTabProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState<string>('all');
|
||||||
|
const [items, setItems] = useState<UserActivityItem[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [initialLoaded, setInitialLoaded] = useState(false);
|
||||||
|
|
||||||
|
const activeTypes = FILTERS.find((f) => f.key === filter)?.types ?? null;
|
||||||
|
|
||||||
|
const load = useCallback(
|
||||||
|
async (loadOffset = 0, append = false) => {
|
||||||
|
if (!userId) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await adminUsersApi.getUserActivity(
|
||||||
|
userId,
|
||||||
|
loadOffset,
|
||||||
|
PAGE_SIZE,
|
||||||
|
activeTypes ?? undefined,
|
||||||
|
);
|
||||||
|
setItems((prev) => (append ? [...prev, ...data.items] : data.items));
|
||||||
|
setTotal(data.total);
|
||||||
|
setOffset(loadOffset + data.items.length);
|
||||||
|
} catch {
|
||||||
|
// background list load — silent, консистентно с остальной карточкой
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
setInitialLoaded(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[userId, activeTypes],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItems([]);
|
||||||
|
setInitialLoaded(false);
|
||||||
|
void load(0, false);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<StatCard
|
||||||
|
label={t('admin.users.detail.activity.total')}
|
||||||
|
value={total}
|
||||||
|
icon={<HistoryIcon className="h-5 w-5" />}
|
||||||
|
tone="accent"
|
||||||
|
loading={!initialLoaded && loading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter chips */}
|
||||||
|
<div
|
||||||
|
className="scrollbar-hide -mx-4 flex gap-2 overflow-x-auto px-4 py-1"
|
||||||
|
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||||
|
>
|
||||||
|
{FILTERS.map(({ key }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setFilter(key)}
|
||||||
|
className={`shrink-0 whitespace-nowrap rounded-full px-3 py-1.5 text-xs font-medium transition-all ${
|
||||||
|
filter === key
|
||||||
|
? 'bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/30'
|
||||||
|
: 'bg-dark-800/50 text-dark-400 active:bg-dark-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`admin.users.detail.activity.filters.${key}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
{!initialLoaded && loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-xl bg-dark-800/50 py-12">
|
||||||
|
<HistoryIcon className="mb-3 h-12 w-12 text-dark-600" />
|
||||||
|
<p className="text-dark-400">{t('admin.users.detail.activity.empty')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const visual = TYPE_VISUALS[item.type] || FALLBACK_VISUAL;
|
||||||
|
const Icon = visual.icon;
|
||||||
|
const typeLabel =
|
||||||
|
t(`admin.users.detail.activity.types.${item.type}`, { defaultValue: '' }) ||
|
||||||
|
item.type;
|
||||||
|
const isLast = index === items.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${item.type}-${item.timestamp}-${index}`} className="flex gap-3">
|
||||||
|
{/* Icon column + connecting line */}
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${visual.tint}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
{!isLast && <div className="w-px flex-1 bg-dark-700/50" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className={`min-w-0 flex-1 ${isLast ? '' : 'pb-4'}`}>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<span className="text-sm font-medium text-dark-100">{typeLabel}</span>
|
||||||
|
{item.subtype && <SubtypeBadge subtype={item.subtype} />}
|
||||||
|
{item.source && (
|
||||||
|
<span className="rounded-full bg-dark-700/40 px-2 py-0.5 text-[10px] uppercase tracking-wider text-dark-500">
|
||||||
|
{t(`admin.users.detail.activity.sources.${item.source}`, {
|
||||||
|
defaultValue: item.source,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<AmountChip item={item} />
|
||||||
|
</div>
|
||||||
|
{item.title && (
|
||||||
|
<p className="mt-0.5 break-words text-sm text-dark-400" title={item.title}>
|
||||||
|
{item.title.length > 160 ? `${item.title.slice(0, 160)}…` : item.title}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-xs text-dark-500" title={formatDate(item.timestamp)}>
|
||||||
|
{formatRelativeTime(item.timestamp, t)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Load more */}
|
||||||
|
{items.length < total && (
|
||||||
|
<button
|
||||||
|
onClick={() => void load(offset, true)}
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-3 flex min-h-[44px] w-full items-center justify-center rounded-lg bg-dark-700/50 py-2.5 text-sm text-dark-300 transition-colors hover:bg-dark-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
t('admin.users.detail.loadMore')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/components/backgrounds/BackgroundHost.tsx
Normal file
45
src/components/backgrounds/BackgroundHost.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { BackgroundRenderer } from './BackgroundRenderer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Держит анимированный фон смонтированным ПОВЕРХ смены роутов.
|
||||||
|
*
|
||||||
|
* Каждый роут оборачивается в собственный <Layout>, поэтому AppShell
|
||||||
|
* перемонтируется при каждой навигации — если рендерить BackgroundRenderer
|
||||||
|
* прямо из AppShell, анимация фона стартует заново на каждой странице.
|
||||||
|
*
|
||||||
|
* Вместо этого AppShell лишь регистрирует своё присутствие через
|
||||||
|
* useBackgroundConsumer(), а BackgroundHost живёт в App (над <Routes>, не
|
||||||
|
* перемонтируется) и держит один экземпляр BackgroundRenderer, пока есть хоть
|
||||||
|
* один потребитель. Страницы без AppShell (логин, QuickPurchase со своим
|
||||||
|
* экземпляром) фон, как и раньше, не получают.
|
||||||
|
*/
|
||||||
|
const useBackgroundConsumers = create<{ count: number }>(() => ({ count: 0 }));
|
||||||
|
|
||||||
|
/** Вызывается из AppShell: «на этом роуте должен быть анимированный фон». */
|
||||||
|
export function useBackgroundConsumer() {
|
||||||
|
useEffect(() => {
|
||||||
|
useBackgroundConsumers.setState((s) => ({ count: s.count + 1 }));
|
||||||
|
return () => useBackgroundConsumers.setState((s) => ({ count: s.count - 1 }));
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackgroundHost() {
|
||||||
|
const active = useBackgroundConsumers((s) => s.count > 0);
|
||||||
|
const [mounted, setMounted] = useState(active);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active) {
|
||||||
|
setMounted(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Грейс-период на случай, когда между размонтированием старого AppShell и
|
||||||
|
// маунтом нового есть зазор (StrictMode, Suspense): без него фон моргал бы
|
||||||
|
// и на таких переходах.
|
||||||
|
const timer = setTimeout(() => setMounted(false), 300);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
return mounted ? <BackgroundRenderer /> : null;
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
|
|
||||||
import { MobileBottomNav } from './MobileBottomNav';
|
import { MobileBottomNav } from './MobileBottomNav';
|
||||||
import { AppHeader } from './AppHeader';
|
import { AppHeader } from './AppHeader';
|
||||||
import { BackgroundRenderer } from '@/components/backgrounds/BackgroundRenderer';
|
import { useBackgroundConsumer } from '@/components/backgrounds/BackgroundHost';
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -60,6 +60,9 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding();
|
const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding();
|
||||||
const { referralEnabled, wheelEnabled, hasContests, hasPolls, giftEnabled } = useFeatureFlags();
|
const { referralEnabled, wheelEnabled, hasContests, hasPolls, giftEnabled } = useFeatureFlags();
|
||||||
useScrollRestoration();
|
useScrollRestoration();
|
||||||
|
// Анимированный фон рендерит BackgroundHost в App (не перемонтируется при
|
||||||
|
// смене роута) — здесь только регистрируем, что на этом роуте он нужен.
|
||||||
|
useBackgroundConsumer();
|
||||||
|
|
||||||
// Theme toggle visibility
|
// Theme toggle visibility
|
||||||
const { data: enabledThemes } = useQuery({
|
const { data: enabledThemes } = useQuery({
|
||||||
@@ -180,9 +183,6 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-viewport">
|
<div className="min-h-viewport">
|
||||||
{/* Animated background renders via portal on document.body at z-index: -1 */}
|
|
||||||
<BackgroundRenderer />
|
|
||||||
|
|
||||||
{/* Global components */}
|
{/* Global components */}
|
||||||
<WebSocketNotifications />
|
<WebSocketNotifications />
|
||||||
<CampaignBonusNotifier />
|
<CampaignBonusNotifier />
|
||||||
@@ -190,7 +190,11 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
<PromptDialogHost />
|
<PromptDialogHost />
|
||||||
|
|
||||||
{/* Desktop Header */}
|
{/* Desktop Header */}
|
||||||
<header className="fixed left-0 right-0 top-0 z-50 hidden border-b border-dark-800/50 bg-dark-950/95 lg:block">
|
{/* w-screen вместо left-0 right-0: right-0 упирается в край вьюпорта БЕЗ
|
||||||
|
скроллбара, и капсула по центру прыгала бы на полширины скроллбара при
|
||||||
|
переходах между страницами со скроллом и без. 100vw даёт ту же ось
|
||||||
|
центрирования, что и у body (тоже 100vw). */}
|
||||||
|
<header className="fixed left-0 top-0 z-50 hidden w-screen border-b border-dark-800/50 bg-dark-950/95 lg:block">
|
||||||
{/* 3-зонный grid: лого | капсула | действия. Колонки 1fr_auto_1fr держат
|
{/* 3-зонный grid: лого | капсула | действия. Колонки 1fr_auto_1fr держат
|
||||||
капсулу строго по центру вьюпорта НЕЗАВИСИМО от ширины лого/действий,
|
капсулу строго по центру вьюпорта НЕЗАВИСИМО от ширины лого/действий,
|
||||||
а действия — у правого края. Поэтому ничего не «скачет» при переходах
|
а действия — у правого края. Поэтому ничего не «скачет» при переходах
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ export function TariffPickerGrid({
|
|||||||
purchaseOptions &&
|
purchaseOptions &&
|
||||||
'subscription_is_expired' in purchaseOptions &&
|
'subscription_is_expired' in purchaseOptions &&
|
||||||
purchaseOptions.subscription_is_expired === true;
|
purchaseOptions.subscription_is_expired === true;
|
||||||
|
// Free (0₽) source tariff: the backend blocks the prorated switch
|
||||||
|
// (free_tariff_cannot_switch) — offer the purchase flow instead.
|
||||||
|
const isOnFreeTariff =
|
||||||
|
isTariffsMode &&
|
||||||
|
purchaseOptions &&
|
||||||
|
'subscription_on_free_tariff' in purchaseOptions &&
|
||||||
|
purchaseOptions.subscription_on_free_tariff === true;
|
||||||
const canSwitch =
|
const canSwitch =
|
||||||
!isMultiTariff &&
|
!isMultiTariff &&
|
||||||
subscription &&
|
subscription &&
|
||||||
@@ -144,6 +151,7 @@ export function TariffPickerGrid({
|
|||||||
!isCurrentTariff &&
|
!isCurrentTariff &&
|
||||||
!subscription.is_trial &&
|
!subscription.is_trial &&
|
||||||
!isSubscriptionExpired &&
|
!isSubscriptionExpired &&
|
||||||
|
!isOnFreeTariff &&
|
||||||
(subscription.is_active || subscription.is_limited);
|
(subscription.is_active || subscription.is_limited);
|
||||||
const isLegacySubscription =
|
const isLegacySubscription =
|
||||||
subscription && !subscription.is_trial && !subscription.tariff_id;
|
subscription && !subscription.is_trial && !subscription.tariff_id;
|
||||||
|
|||||||
@@ -27,11 +27,13 @@ import type { Tariff } from '../../../types';
|
|||||||
// ──────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// The backend rejects a switch that must instead go through the purchase flow:
|
// The backend rejects a switch that must instead go through the purchase flow:
|
||||||
// the subscription lapsed (`subscription_expired`), or it is a trial that has no
|
// the subscription lapsed (`subscription_expired`), it is a trial that has no
|
||||||
// paid value to prorate and would otherwise be handed a full target period
|
// paid value to prorate and would otherwise be handed a full target period
|
||||||
// (`trial_cannot_switch`, bug #629889). Both arrive as detail.code +
|
// (`trial_cannot_switch`, bug #629889), or it sits on a free 0₽ tariff whose
|
||||||
// use_purchase_flow=true; some payloads use the legacy `error_code` key, so we
|
// spammed/gifted remainder must reset rather than be prorated and carried
|
||||||
// accept either.
|
// (`free_tariff_cannot_switch`, TARIFF_SWITCH_RESET_FREE_DAYS). All arrive as
|
||||||
|
// detail.code + use_purchase_flow=true; some payloads use the legacy
|
||||||
|
// `error_code` key, so we accept either.
|
||||||
function shouldUsePurchaseFlow(error: unknown): boolean {
|
function shouldUsePurchaseFlow(error: unknown): boolean {
|
||||||
if (!(error instanceof AxiosError)) return false;
|
if (!(error instanceof AxiosError)) return false;
|
||||||
const detail = error.response?.data?.detail as
|
const detail = error.response?.data?.detail as
|
||||||
@@ -40,7 +42,9 @@ function shouldUsePurchaseFlow(error: unknown): boolean {
|
|||||||
if (!detail || typeof detail !== 'object') return false;
|
if (!detail || typeof detail !== 'object') return false;
|
||||||
const code = detail.code ?? detail.error_code;
|
const code = detail.code ?? detail.error_code;
|
||||||
return (
|
return (
|
||||||
(code === 'subscription_expired' || code === 'trial_cannot_switch') &&
|
(code === 'subscription_expired' ||
|
||||||
|
code === 'trial_cannot_switch' ||
|
||||||
|
code === 'free_tariff_cannot_switch') &&
|
||||||
detail.use_purchase_flow === true
|
detail.use_purchase_flow === true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1947,6 +1947,7 @@
|
|||||||
"customButtonTypeUrl": "URL",
|
"customButtonTypeUrl": "URL",
|
||||||
"customButtonCallbackPlaceholder": "callback_data (e.g. back_to_menu)",
|
"customButtonCallbackPlaceholder": "callback_data (e.g. back_to_menu)",
|
||||||
"customButtonUrlPlaceholder": "https://example.com",
|
"customButtonUrlPlaceholder": "https://example.com",
|
||||||
|
"customButtonEmojiIdPlaceholder": "Custom emoji ID (optional)",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"previewEmpty": "— empty —",
|
"previewEmpty": "— empty —",
|
||||||
"emailSubjectLabel": "Subject",
|
"emailSubjectLabel": "Subject",
|
||||||
@@ -3378,7 +3379,8 @@
|
|||||||
"sync": "Synchronization",
|
"sync": "Synchronization",
|
||||||
"tickets": "Tickets",
|
"tickets": "Tickets",
|
||||||
"gifts": "Gifts",
|
"gifts": "Gifts",
|
||||||
"referrals": "Referrals"
|
"referrals": "Referrals",
|
||||||
|
"activity": "Activity"
|
||||||
},
|
},
|
||||||
"noTickets": "No tickets from this user",
|
"noTickets": "No tickets from this user",
|
||||||
"ticketsCount": "tickets",
|
"ticketsCount": "tickets",
|
||||||
@@ -3579,6 +3581,70 @@
|
|||||||
"searchPlaceholder": "Search by name, email, or Telegram ID...",
|
"searchPlaceholder": "Search by name, email, or Telegram ID...",
|
||||||
"alreadyReferred": "already has referrer",
|
"alreadyReferred": "already has referrer",
|
||||||
"noUsersFound": "No users found"
|
"noUsersFound": "No users found"
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"total": "Total events",
|
||||||
|
"empty": "No activity yet",
|
||||||
|
"filters": {
|
||||||
|
"all": "All",
|
||||||
|
"payments": "Payments",
|
||||||
|
"events": "Events",
|
||||||
|
"promo": "Promo",
|
||||||
|
"tickets": "Tickets",
|
||||||
|
"gifts": "Gifts",
|
||||||
|
"referrals": "Referrals",
|
||||||
|
"logins": "Logins",
|
||||||
|
"clicks": "Clicks"
|
||||||
|
},
|
||||||
|
"types": {
|
||||||
|
"transaction": "Transaction",
|
||||||
|
"event": "Event",
|
||||||
|
"promocode": "Promo code",
|
||||||
|
"coupon": "Coupon",
|
||||||
|
"ticket": "Ticket",
|
||||||
|
"wheel_spin": "Fortune wheel",
|
||||||
|
"poll": "Poll completed",
|
||||||
|
"gift_sent": "Gift sent",
|
||||||
|
"gift_received": "Gift received",
|
||||||
|
"referral_earning": "Referral earning",
|
||||||
|
"cabinet_login": "Cabinet login",
|
||||||
|
"withdrawal": "Withdrawal request",
|
||||||
|
"button_click": "Bot button",
|
||||||
|
"cabinet_action": "Cabinet action"
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"bot": "Bot",
|
||||||
|
"cabinet": "Cabinet"
|
||||||
|
},
|
||||||
|
"subtypes": {
|
||||||
|
"deposit": "Deposit",
|
||||||
|
"withdrawal": "Charge",
|
||||||
|
"subscription_payment": "Subscription payment",
|
||||||
|
"refund": "Refund",
|
||||||
|
"failed_refund": "Failed refund",
|
||||||
|
"referral_reward": "Referral reward",
|
||||||
|
"poll_reward": "Poll reward",
|
||||||
|
"gift_payment": "Gift payment",
|
||||||
|
"activation": "Activation",
|
||||||
|
"purchase": "Purchase",
|
||||||
|
"subscription_purchase": "Subscription purchase",
|
||||||
|
"renewal": "Renewal",
|
||||||
|
"balance_topup": "Balance top-up",
|
||||||
|
"promo_group_change": "Promo group change",
|
||||||
|
"campaign_registration": "Campaign registration",
|
||||||
|
"referral_link_visit": "Referral link visit",
|
||||||
|
"referral_registration": "Referral registration",
|
||||||
|
"trial_activation": "Trial activation",
|
||||||
|
"open": "Open",
|
||||||
|
"answered": "Answered",
|
||||||
|
"pending": "Pending",
|
||||||
|
"closed": "Closed",
|
||||||
|
"redeemed": "Redeemed",
|
||||||
|
"approved": "Approved",
|
||||||
|
"rejected": "Rejected",
|
||||||
|
"completed": "Completed",
|
||||||
|
"command": "Command"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notFound": "User not found",
|
"notFound": "User not found",
|
||||||
|
|||||||
@@ -1580,6 +1580,7 @@
|
|||||||
"customButtonTypeUrl": "لینک",
|
"customButtonTypeUrl": "لینک",
|
||||||
"customButtonCallbackPlaceholder": "callback_data (مثلاً back_to_menu)",
|
"customButtonCallbackPlaceholder": "callback_data (مثلاً back_to_menu)",
|
||||||
"customButtonUrlPlaceholder": "https://example.com",
|
"customButtonUrlPlaceholder": "https://example.com",
|
||||||
|
"customButtonEmojiIdPlaceholder": "شناسه ایموجی سفارشی (اختیاری)",
|
||||||
"category": "دستهبندی",
|
"category": "دستهبندی",
|
||||||
"categoryNews": "اخبار",
|
"categoryNews": "اخبار",
|
||||||
"categoryPromo": "تبلیغاتی",
|
"categoryPromo": "تبلیغاتی",
|
||||||
@@ -2882,7 +2883,8 @@
|
|||||||
"sync": "همگامسازی",
|
"sync": "همگامسازی",
|
||||||
"tickets": "تیکتها",
|
"tickets": "تیکتها",
|
||||||
"gifts": "هدایا",
|
"gifts": "هدایا",
|
||||||
"referrals": "معرفیها"
|
"referrals": "معرفیها",
|
||||||
|
"activity": "فعالیت"
|
||||||
},
|
},
|
||||||
"noTickets": "این کاربر تیکتی ندارد",
|
"noTickets": "این کاربر تیکتی ندارد",
|
||||||
"ticketsCount": "تیکت",
|
"ticketsCount": "تیکت",
|
||||||
@@ -3083,7 +3085,71 @@
|
|||||||
"areYouSure": "آیا مطمئنید میخواهید ادامه دهید؟",
|
"areYouSure": "آیا مطمئنید میخواهید ادامه دهید؟",
|
||||||
"title": "عملیات"
|
"title": "عملیات"
|
||||||
},
|
},
|
||||||
"noVpnData": "دادهای از VPN وجود ندارد"
|
"noVpnData": "دادهای از VPN وجود ندارد",
|
||||||
|
"activity": {
|
||||||
|
"total": "مجموع رویدادها",
|
||||||
|
"empty": "هنوز فعالیتی وجود ندارد",
|
||||||
|
"filters": {
|
||||||
|
"all": "همه",
|
||||||
|
"payments": "پرداختها",
|
||||||
|
"events": "رویدادها",
|
||||||
|
"promo": "تخفیفها",
|
||||||
|
"tickets": "تیکتها",
|
||||||
|
"gifts": "هدایا",
|
||||||
|
"referrals": "دعوتها",
|
||||||
|
"logins": "ورودها",
|
||||||
|
"clicks": "کلیکها"
|
||||||
|
},
|
||||||
|
"types": {
|
||||||
|
"transaction": "تراکنش",
|
||||||
|
"event": "رویداد",
|
||||||
|
"promocode": "کد تخفیف",
|
||||||
|
"coupon": "کوپن",
|
||||||
|
"ticket": "تیکت",
|
||||||
|
"wheel_spin": "گردونه شانس",
|
||||||
|
"poll": "نظرسنجی تکمیل شد",
|
||||||
|
"gift_sent": "هدیه ارسال شد",
|
||||||
|
"gift_received": "هدیه دریافت شد",
|
||||||
|
"referral_earning": "درآمد دعوت",
|
||||||
|
"cabinet_login": "ورود به پنل",
|
||||||
|
"withdrawal": "درخواست برداشت",
|
||||||
|
"button_click": "دکمه ربات",
|
||||||
|
"cabinet_action": "اقدام در پنل"
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"bot": "ربات",
|
||||||
|
"cabinet": "پنل"
|
||||||
|
},
|
||||||
|
"subtypes": {
|
||||||
|
"deposit": "واریز",
|
||||||
|
"withdrawal": "برداشت",
|
||||||
|
"subscription_payment": "پرداخت اشتراک",
|
||||||
|
"refund": "بازپرداخت",
|
||||||
|
"failed_refund": "بازپرداخت ناموفق",
|
||||||
|
"referral_reward": "پاداش دعوت",
|
||||||
|
"poll_reward": "پاداش نظرسنجی",
|
||||||
|
"gift_payment": "پرداخت هدیه",
|
||||||
|
"activation": "فعالسازی",
|
||||||
|
"purchase": "خرید",
|
||||||
|
"subscription_purchase": "خرید اشتراک",
|
||||||
|
"renewal": "تمدید",
|
||||||
|
"balance_topup": "شارژ موجودی",
|
||||||
|
"promo_group_change": "تغییر گروه تخفیف",
|
||||||
|
"campaign_registration": "ثبتنام کمپین",
|
||||||
|
"referral_link_visit": "بازدید لینک دعوت",
|
||||||
|
"referral_registration": "ثبتنام دعوتی",
|
||||||
|
"trial_activation": "فعالسازی دوره آزمایشی",
|
||||||
|
"open": "باز",
|
||||||
|
"answered": "پاسخ داده شده",
|
||||||
|
"pending": "در انتظار",
|
||||||
|
"closed": "بسته",
|
||||||
|
"redeemed": "استفاده شده",
|
||||||
|
"approved": "تأیید شده",
|
||||||
|
"rejected": "رد شده",
|
||||||
|
"completed": "انجام شده",
|
||||||
|
"command": "دستور"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notFound": "کاربر یافت نشد",
|
"notFound": "کاربر یافت نشد",
|
||||||
"purchaseCount": "تعداد خرید: {{count}}",
|
"purchaseCount": "تعداد خرید: {{count}}",
|
||||||
|
|||||||
@@ -1974,6 +1974,7 @@
|
|||||||
"customButtonTypeUrl": "Ссылка",
|
"customButtonTypeUrl": "Ссылка",
|
||||||
"customButtonCallbackPlaceholder": "callback_data (например, back_to_menu)",
|
"customButtonCallbackPlaceholder": "callback_data (например, back_to_menu)",
|
||||||
"customButtonUrlPlaceholder": "https://example.com",
|
"customButtonUrlPlaceholder": "https://example.com",
|
||||||
|
"customButtonEmojiIdPlaceholder": "Custom emoji ID (необязательно)",
|
||||||
"preview": "Предпросмотр",
|
"preview": "Предпросмотр",
|
||||||
"previewEmpty": "— пусто —",
|
"previewEmpty": "— пусто —",
|
||||||
"emailSubjectLabel": "Тема",
|
"emailSubjectLabel": "Тема",
|
||||||
@@ -3785,7 +3786,8 @@
|
|||||||
"sync": "Синхронизация",
|
"sync": "Синхронизация",
|
||||||
"tickets": "Тикеты",
|
"tickets": "Тикеты",
|
||||||
"gifts": "Подарки",
|
"gifts": "Подарки",
|
||||||
"referrals": "Рефералы"
|
"referrals": "Рефералы",
|
||||||
|
"activity": "Активность"
|
||||||
},
|
},
|
||||||
"noTickets": "У пользователя нет тикетов",
|
"noTickets": "У пользователя нет тикетов",
|
||||||
"ticketsCount": "тикетов",
|
"ticketsCount": "тикетов",
|
||||||
@@ -3985,6 +3987,70 @@
|
|||||||
"searchPlaceholder": "Поиск по имени, email или Telegram ID...",
|
"searchPlaceholder": "Поиск по имени, email или Telegram ID...",
|
||||||
"alreadyReferred": "уже чей-то реферал",
|
"alreadyReferred": "уже чей-то реферал",
|
||||||
"noUsersFound": "Пользователи не найдены"
|
"noUsersFound": "Пользователи не найдены"
|
||||||
|
},
|
||||||
|
"activity": {
|
||||||
|
"total": "Всего событий",
|
||||||
|
"empty": "Активности пока нет",
|
||||||
|
"filters": {
|
||||||
|
"all": "Все",
|
||||||
|
"payments": "Платежи",
|
||||||
|
"events": "События",
|
||||||
|
"promo": "Промо",
|
||||||
|
"tickets": "Тикеты",
|
||||||
|
"gifts": "Подарки",
|
||||||
|
"referrals": "Рефералы",
|
||||||
|
"logins": "Входы",
|
||||||
|
"clicks": "Клики"
|
||||||
|
},
|
||||||
|
"types": {
|
||||||
|
"transaction": "Транзакция",
|
||||||
|
"event": "Событие",
|
||||||
|
"promocode": "Промокод",
|
||||||
|
"coupon": "Купон",
|
||||||
|
"ticket": "Тикет",
|
||||||
|
"wheel_spin": "Колесо фортуны",
|
||||||
|
"poll": "Опрос пройден",
|
||||||
|
"gift_sent": "Подарок отправлен",
|
||||||
|
"gift_received": "Подарок получен",
|
||||||
|
"referral_earning": "Реферальное начисление",
|
||||||
|
"cabinet_login": "Вход в кабинет",
|
||||||
|
"withdrawal": "Заявка на вывод",
|
||||||
|
"button_click": "Кнопка в боте",
|
||||||
|
"cabinet_action": "Действие в кабинете"
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"bot": "Бот",
|
||||||
|
"cabinet": "Кабинет"
|
||||||
|
},
|
||||||
|
"subtypes": {
|
||||||
|
"deposit": "Пополнение",
|
||||||
|
"withdrawal": "Списание",
|
||||||
|
"subscription_payment": "Оплата подписки",
|
||||||
|
"refund": "Возврат",
|
||||||
|
"failed_refund": "Неудачный возврат",
|
||||||
|
"referral_reward": "Реферальная награда",
|
||||||
|
"poll_reward": "Награда за опрос",
|
||||||
|
"gift_payment": "Оплата подарка",
|
||||||
|
"activation": "Активация",
|
||||||
|
"purchase": "Покупка",
|
||||||
|
"subscription_purchase": "Покупка подписки",
|
||||||
|
"renewal": "Продление",
|
||||||
|
"balance_topup": "Пополнение баланса",
|
||||||
|
"promo_group_change": "Смена промогруппы",
|
||||||
|
"campaign_registration": "Регистрация по кампании",
|
||||||
|
"referral_link_visit": "Переход по реф. ссылке",
|
||||||
|
"referral_registration": "Реф. регистрация",
|
||||||
|
"trial_activation": "Активация триала",
|
||||||
|
"open": "Открыт",
|
||||||
|
"answered": "Отвечен",
|
||||||
|
"pending": "Ожидает",
|
||||||
|
"closed": "Закрыт",
|
||||||
|
"redeemed": "Погашен",
|
||||||
|
"approved": "Одобрена",
|
||||||
|
"rejected": "Отклонена",
|
||||||
|
"completed": "Выполнена",
|
||||||
|
"command": "Команда"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"notFound": "Пользователь не найден",
|
"notFound": "Пользователь не найден",
|
||||||
|
|||||||
@@ -1681,6 +1681,7 @@
|
|||||||
"customButtonTypeUrl": "链接",
|
"customButtonTypeUrl": "链接",
|
||||||
"customButtonCallbackPlaceholder": "callback_data(例如 back_to_menu)",
|
"customButtonCallbackPlaceholder": "callback_data(例如 back_to_menu)",
|
||||||
"customButtonUrlPlaceholder": "https://example.com",
|
"customButtonUrlPlaceholder": "https://example.com",
|
||||||
|
"customButtonEmojiIdPlaceholder": "自定义 Emoji ID(可选)",
|
||||||
"category": "类别",
|
"category": "类别",
|
||||||
"categoryNews": "新闻",
|
"categoryNews": "新闻",
|
||||||
"categoryPromo": "促销",
|
"categoryPromo": "促销",
|
||||||
@@ -2881,7 +2882,8 @@
|
|||||||
"sync": "同步",
|
"sync": "同步",
|
||||||
"tickets": "工单",
|
"tickets": "工单",
|
||||||
"gifts": "礼物",
|
"gifts": "礼物",
|
||||||
"referrals": "推荐"
|
"referrals": "推荐",
|
||||||
|
"activity": "活动记录"
|
||||||
},
|
},
|
||||||
"noTickets": "该用户没有工单",
|
"noTickets": "该用户没有工单",
|
||||||
"ticketsCount": "个工单",
|
"ticketsCount": "个工单",
|
||||||
@@ -3082,7 +3084,71 @@
|
|||||||
"areYouSure": "确定要继续?",
|
"areYouSure": "确定要继续?",
|
||||||
"title": "操作"
|
"title": "操作"
|
||||||
},
|
},
|
||||||
"noVpnData": "无 VPN 数据"
|
"noVpnData": "无 VPN 数据",
|
||||||
|
"activity": {
|
||||||
|
"total": "事件总数",
|
||||||
|
"empty": "暂无活动记录",
|
||||||
|
"filters": {
|
||||||
|
"all": "全部",
|
||||||
|
"payments": "支付",
|
||||||
|
"events": "事件",
|
||||||
|
"promo": "促销",
|
||||||
|
"tickets": "工单",
|
||||||
|
"gifts": "礼物",
|
||||||
|
"referrals": "推荐",
|
||||||
|
"logins": "登录",
|
||||||
|
"clicks": "点击"
|
||||||
|
},
|
||||||
|
"types": {
|
||||||
|
"transaction": "交易",
|
||||||
|
"event": "事件",
|
||||||
|
"promocode": "促销码",
|
||||||
|
"coupon": "优惠券",
|
||||||
|
"ticket": "工单",
|
||||||
|
"wheel_spin": "幸运转盘",
|
||||||
|
"poll": "问卷已完成",
|
||||||
|
"gift_sent": "已发送礼物",
|
||||||
|
"gift_received": "已收到礼物",
|
||||||
|
"referral_earning": "推荐收益",
|
||||||
|
"cabinet_login": "登录后台",
|
||||||
|
"withdrawal": "提现申请",
|
||||||
|
"button_click": "机器人按钮",
|
||||||
|
"cabinet_action": "后台操作"
|
||||||
|
},
|
||||||
|
"sources": {
|
||||||
|
"bot": "机器人",
|
||||||
|
"cabinet": "后台"
|
||||||
|
},
|
||||||
|
"subtypes": {
|
||||||
|
"deposit": "充值",
|
||||||
|
"withdrawal": "扣款",
|
||||||
|
"subscription_payment": "订阅付款",
|
||||||
|
"refund": "退款",
|
||||||
|
"failed_refund": "退款失败",
|
||||||
|
"referral_reward": "推荐奖励",
|
||||||
|
"poll_reward": "问卷奖励",
|
||||||
|
"gift_payment": "礼物付款",
|
||||||
|
"activation": "激活",
|
||||||
|
"purchase": "购买",
|
||||||
|
"subscription_purchase": "订阅购买",
|
||||||
|
"renewal": "续订",
|
||||||
|
"balance_topup": "余额充值",
|
||||||
|
"promo_group_change": "促销组变更",
|
||||||
|
"campaign_registration": "活动注册",
|
||||||
|
"referral_link_visit": "推荐链接访问",
|
||||||
|
"referral_registration": "推荐注册",
|
||||||
|
"trial_activation": "试用激活",
|
||||||
|
"open": "打开",
|
||||||
|
"answered": "已回复",
|
||||||
|
"pending": "等待中",
|
||||||
|
"closed": "已关闭",
|
||||||
|
"redeemed": "已兑换",
|
||||||
|
"approved": "已批准",
|
||||||
|
"rejected": "已拒绝",
|
||||||
|
"completed": "已完成",
|
||||||
|
"command": "命令"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"notFound": "未找到用户",
|
"notFound": "未找到用户",
|
||||||
"purchaseCount": "购买次数:{{count}}",
|
"purchaseCount": "购买次数:{{count}}",
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
adminBroadcastsApi,
|
adminBroadcastsApi,
|
||||||
BroadcastFilter,
|
type BroadcastFilter,
|
||||||
TariffFilter,
|
type TariffFilter,
|
||||||
CombinedBroadcastCreateRequest,
|
type CombinedBroadcastCreateRequest,
|
||||||
CustomBroadcastButton,
|
type CustomBroadcastButton,
|
||||||
} from '../api/adminBroadcasts';
|
} from '../api/adminBroadcasts';
|
||||||
import { AdminBackButton } from '../components/admin';
|
import { AdminBackButton } from '../components/admin';
|
||||||
import { TelegramPreview, EmailPreview } from '../components/broadcasts/BroadcastPreview';
|
import { TelegramPreview, EmailPreview } from '../components/broadcasts/BroadcastPreview';
|
||||||
@@ -63,6 +63,7 @@ export default function AdminBroadcastCreate() {
|
|||||||
const [newButtonLabel, setNewButtonLabel] = useState('');
|
const [newButtonLabel, setNewButtonLabel] = useState('');
|
||||||
const [newButtonActionType, setNewButtonActionType] = useState<'callback' | 'url'>('callback');
|
const [newButtonActionType, setNewButtonActionType] = useState<'callback' | 'url'>('callback');
|
||||||
const [newButtonActionValue, setNewButtonActionValue] = useState('');
|
const [newButtonActionValue, setNewButtonActionValue] = useState('');
|
||||||
|
const [newButtonEmojiId, setNewButtonEmojiId] = useState('');
|
||||||
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
||||||
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo');
|
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo');
|
||||||
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
|
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
|
||||||
@@ -305,6 +306,8 @@ export default function AdminBroadcastCreate() {
|
|||||||
// Custom button validation
|
// Custom button validation
|
||||||
const isNewButtonValid = useMemo(() => {
|
const isNewButtonValid = useMemo(() => {
|
||||||
if (!newButtonLabel.trim() || !newButtonActionValue.trim()) return false;
|
if (!newButtonLabel.trim() || !newButtonActionValue.trim()) return false;
|
||||||
|
// custom_emoji_id — необязательное поле, но если задано — числовая строка (Bot API)
|
||||||
|
if (newButtonEmojiId.trim() && !/^\d{1,64}$/.test(newButtonEmojiId.trim())) return false;
|
||||||
if (newButtonActionType === 'url') {
|
if (newButtonActionType === 'url') {
|
||||||
return /^https:\/\/|^tg:\/\//.test(newButtonActionValue.trim());
|
return /^https:\/\/|^tg:\/\//.test(newButtonActionValue.trim());
|
||||||
}
|
}
|
||||||
@@ -312,22 +315,25 @@ export default function AdminBroadcastCreate() {
|
|||||||
return new TextEncoder().encode(newButtonActionValue.trim()).length <= 64;
|
return new TextEncoder().encode(newButtonActionValue.trim()).length <= 64;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}, [newButtonLabel, newButtonActionType, newButtonActionValue]);
|
}, [newButtonLabel, newButtonActionType, newButtonActionValue, newButtonEmojiId]);
|
||||||
|
|
||||||
// Custom button handlers
|
// Custom button handlers
|
||||||
const addCustomButton = () => {
|
const addCustomButton = () => {
|
||||||
if (!isNewButtonValid) return;
|
if (!isNewButtonValid) return;
|
||||||
|
const emojiId = newButtonEmojiId.trim();
|
||||||
setCustomButtons((prev) => [
|
setCustomButtons((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{
|
||||||
label: newButtonLabel.trim(),
|
label: newButtonLabel.trim(),
|
||||||
action_type: newButtonActionType,
|
action_type: newButtonActionType,
|
||||||
action_value: newButtonActionValue.trim(),
|
action_value: newButtonActionValue.trim(),
|
||||||
|
...(emojiId ? { icon_custom_emoji_id: emojiId } : {}),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
setNewButtonLabel('');
|
setNewButtonLabel('');
|
||||||
setNewButtonActionValue('');
|
setNewButtonActionValue('');
|
||||||
setNewButtonActionType('callback');
|
setNewButtonActionType('callback');
|
||||||
|
setNewButtonEmojiId('');
|
||||||
setIsAddingCustomButton(false);
|
setIsAddingCustomButton(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -800,6 +806,15 @@ export default function AdminBroadcastCreate() {
|
|||||||
maxLength={newButtonActionType === 'callback' ? 64 : 256}
|
maxLength={newButtonActionType === 'callback' ? 64 : 256}
|
||||||
className="input"
|
className="input"
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={newButtonEmojiId}
|
||||||
|
onChange={(e) => setNewButtonEmojiId(e.target.value)}
|
||||||
|
placeholder={t('admin.broadcasts.customButtonEmojiIdPlaceholder')}
|
||||||
|
maxLength={64}
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -807,6 +822,7 @@ export default function AdminBroadcastCreate() {
|
|||||||
setIsAddingCustomButton(false);
|
setIsAddingCustomButton(false);
|
||||||
setNewButtonLabel('');
|
setNewButtonLabel('');
|
||||||
setNewButtonActionValue('');
|
setNewButtonActionValue('');
|
||||||
|
setNewButtonEmojiId('');
|
||||||
}}
|
}}
|
||||||
className="btn-secondary flex-1"
|
className="btn-secondary flex-1"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { GiftsTab } from '../components/admin/userDetail/GiftsTab';
|
|||||||
import { SyncTab } from '../components/admin/userDetail/SyncTab';
|
import { SyncTab } from '../components/admin/userDetail/SyncTab';
|
||||||
import { ReferralsTab } from '../components/admin/userDetail/ReferralsTab';
|
import { ReferralsTab } from '../components/admin/userDetail/ReferralsTab';
|
||||||
import { BalanceTab } from '../components/admin/userDetail/BalanceTab';
|
import { BalanceTab } from '../components/admin/userDetail/BalanceTab';
|
||||||
|
import { ActivityTab } from '../components/admin/userDetail/ActivityTab';
|
||||||
import { TicketsTab } from '../components/admin/userDetail/TicketsTab';
|
import { TicketsTab } from '../components/admin/userDetail/TicketsTab';
|
||||||
import { InfoTab } from '../components/admin/userDetail/InfoTab';
|
import { InfoTab } from '../components/admin/userDetail/InfoTab';
|
||||||
import { SubscriptionTab } from '../components/admin/userDetail/SubscriptionTab';
|
import { SubscriptionTab } from '../components/admin/userDetail/SubscriptionTab';
|
||||||
@@ -49,7 +50,7 @@ export default function AdminUserDetail() {
|
|||||||
const [user, setUser] = useState<UserDetailResponse | null>(null);
|
const [user, setUser] = useState<UserDetailResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
'info' | 'subscription' | 'balance' | 'sync' | 'tickets' | 'gifts' | 'referrals'
|
'info' | 'subscription' | 'balance' | 'sync' | 'tickets' | 'gifts' | 'referrals' | 'activity'
|
||||||
>('info');
|
>('info');
|
||||||
const [syncStatus, setSyncStatus] = useState<PanelSyncStatusResponse | null>(null);
|
const [syncStatus, setSyncStatus] = useState<PanelSyncStatusResponse | null>(null);
|
||||||
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]);
|
const [tariffs, setTariffs] = useState<UserAvailableTariff[]>([]);
|
||||||
@@ -776,7 +777,18 @@ export default function AdminUserDetail() {
|
|||||||
className="scrollbar-hide -mx-4 mb-6 flex gap-2 overflow-x-auto px-4 py-1"
|
className="scrollbar-hide -mx-4 mb-6 flex gap-2 overflow-x-auto px-4 py-1"
|
||||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||||
>
|
>
|
||||||
{(['info', 'subscription', 'balance', 'sync', 'tickets', 'gifts', 'referrals'] as const)
|
{(
|
||||||
|
[
|
||||||
|
'info',
|
||||||
|
'subscription',
|
||||||
|
'balance',
|
||||||
|
'sync',
|
||||||
|
'tickets',
|
||||||
|
'gifts',
|
||||||
|
'referrals',
|
||||||
|
'activity',
|
||||||
|
] as const
|
||||||
|
)
|
||||||
.filter((tab) => tab !== 'sync' || hasPermission('users:sync'))
|
.filter((tab) => tab !== 'sync' || hasPermission('users:sync'))
|
||||||
.map((tab) => (
|
.map((tab) => (
|
||||||
<button
|
<button
|
||||||
@@ -795,6 +807,7 @@ export default function AdminUserDetail() {
|
|||||||
{tab === 'tickets' && t('admin.users.detail.tabs.tickets')}
|
{tab === 'tickets' && t('admin.users.detail.tabs.tickets')}
|
||||||
{tab === 'gifts' && t('admin.users.detail.tabs.gifts')}
|
{tab === 'gifts' && t('admin.users.detail.tabs.gifts')}
|
||||||
{tab === 'referrals' && t('admin.users.detail.tabs.referrals')}
|
{tab === 'referrals' && t('admin.users.detail.tabs.referrals')}
|
||||||
|
{tab === 'activity' && t('admin.users.detail.tabs.activity')}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -948,6 +961,11 @@ export default function AdminUserDetail() {
|
|||||||
{activeTab === 'referrals' && user && userId && (
|
{activeTab === 'referrals' && user && userId && (
|
||||||
<ReferralsTab user={user} userId={userId} onUserRefresh={loadUser} />
|
<ReferralsTab user={user} userId={userId} onUserRefresh={loadUser} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Activity Tab */}
|
||||||
|
{activeTab === 'activity' && userId && (
|
||||||
|
<ActivityTab userId={userId} formatDate={formatDate} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -235,6 +235,17 @@
|
|||||||
scrollbar-color: rgb(var(--color-dark-700)) transparent;
|
scrollbar-color: rgb(var(--color-dark-700)) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
/* 100vw включает ширину скроллбара, поэтому центрированный контент не
|
||||||
|
сдвигается, когда при навигации появляется/исчезает вертикальный
|
||||||
|
скроллбар; лишние ~6px справа уходят под скроллбар и срезаются
|
||||||
|
overflow-x: hidden на html. Вариант со scrollbar-gutter: stable не
|
||||||
|
подошёл: фоновый слой (fixed-портал) не красит зарезервированный жёлоб,
|
||||||
|
и справа остаётся пустая полоса. На мобильных overlay-скроллбары места
|
||||||
|
не занимают — правило ни на что не влияет. */
|
||||||
|
width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide scrollbar on mobile (iOS/Android) */
|
/* Hide scrollbar on mobile (iOS/Android) */
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
html,
|
html,
|
||||||
|
|||||||
@@ -364,6 +364,9 @@ export interface TariffsPurchaseOptions {
|
|||||||
// New fields for expired subscription handling
|
// New fields for expired subscription handling
|
||||||
subscription_status?: string;
|
subscription_status?: string;
|
||||||
subscription_is_expired?: boolean;
|
subscription_is_expired?: boolean;
|
||||||
|
// Free (0₽) source tariff: switch is blocked (free days must reset),
|
||||||
|
// tariff cards must offer the purchase flow instead of the prorated switch
|
||||||
|
subscription_on_free_tariff?: boolean;
|
||||||
has_subscription?: boolean;
|
has_subscription?: boolean;
|
||||||
// Multi-tariff: all available tariffs already purchased
|
// Multi-tariff: all available tariffs already purchased
|
||||||
all_tariffs_purchased?: boolean;
|
all_tariffs_purchased?: boolean;
|
||||||
|
|||||||
23
src/utils/formatTraffic.test.ts
Normal file
23
src/utils/formatTraffic.test.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { formatTraffic } from './formatTraffic';
|
||||||
|
|
||||||
|
vi.mock('../i18n', () => ({
|
||||||
|
default: { t: (_key: string, fallback: string) => fallback },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('formatTraffic', () => {
|
||||||
|
it('formats terabytes from 1000 GB up', () => {
|
||||||
|
expect(formatTraffic(1000)).toBe('1.0 TB');
|
||||||
|
expect(formatTraffic(2500)).toBe('2.5 TB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats gigabytes from 1 GB up', () => {
|
||||||
|
expect(formatTraffic(1)).toBe('1.0 GB');
|
||||||
|
expect(formatTraffic(999.94)).toBe('999.9 GB');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats sub-gigabyte values as megabytes', () => {
|
||||||
|
expect(formatTraffic(0.5)).toBe('512 MB');
|
||||||
|
expect(formatTraffic(0)).toBe('0 MB');
|
||||||
|
});
|
||||||
|
});
|
||||||
29
src/utils/period.test.ts
Normal file
29
src/utils/period.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { getMonthToDateRange, isMonthToDate } from './period';
|
||||||
|
|
||||||
|
describe('period', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a month-to-date range with zero-padded local dates', () => {
|
||||||
|
vi.setSystemTime(new Date(2026, 2, 5));
|
||||||
|
expect(getMonthToDateRange()).toEqual({ startDate: '2026-03-01', endDate: '2026-03-05' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognises the current month-to-date period', () => {
|
||||||
|
vi.setSystemTime(new Date(2026, 6, 13));
|
||||||
|
expect(isMonthToDate({ startDate: '2026-07-01', endDate: '2026-07-13' })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects day-based and mismatched periods', () => {
|
||||||
|
vi.setSystemTime(new Date(2026, 6, 13));
|
||||||
|
expect(isMonthToDate({ days: 30 })).toBe(false);
|
||||||
|
expect(isMonthToDate({ startDate: '2026-07-01', endDate: '2026-07-12' })).toBe(false);
|
||||||
|
expect(isMonthToDate({})).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/utils/safeRedirect.test.ts
Normal file
36
src/utils/safeRedirect.test.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { getSafeRedirectPath } from './safeRedirect';
|
||||||
|
|
||||||
|
describe('getSafeRedirectPath', () => {
|
||||||
|
it('returns / for empty values', () => {
|
||||||
|
expect(getSafeRedirectPath(null)).toBe('/');
|
||||||
|
expect(getSafeRedirectPath(undefined)).toBe('/');
|
||||||
|
expect(getSafeRedirectPath('')).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps plain absolute in-app paths', () => {
|
||||||
|
expect(getSafeRedirectPath('/')).toBe('/');
|
||||||
|
expect(getSafeRedirectPath('/dashboard')).toBe('/dashboard');
|
||||||
|
expect(getSafeRedirectPath('/sub/page?tab=devices&x=1')).toBe('/sub/page?tab=devices&x=1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects protocol-relative and absolute URLs', () => {
|
||||||
|
expect(getSafeRedirectPath('//evil.com')).toBe('/');
|
||||||
|
expect(getSafeRedirectPath('https://evil.com/path')).toBe('/');
|
||||||
|
expect(getSafeRedirectPath('http://evil.com')).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-path schemes', () => {
|
||||||
|
expect(getSafeRedirectPath('javascript:alert(1)')).toBe('/');
|
||||||
|
expect(getSafeRedirectPath('data:text/html,x')).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects URL-encoded host smuggling', () => {
|
||||||
|
expect(getSafeRedirectPath('%2F%2Fevil.com')).toBe('/');
|
||||||
|
expect(getSafeRedirectPath('/redirect%3A%2F%2Fevil.com')).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed percent-encoding instead of throwing', () => {
|
||||||
|
expect(getSafeRedirectPath('/%E0%A4%A')).toBe('/');
|
||||||
|
});
|
||||||
|
});
|
||||||
28
src/utils/sanitizeSurrogates.test.ts
Normal file
28
src/utils/sanitizeSurrogates.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { sanitizeSurrogates } from './sanitizeSurrogates';
|
||||||
|
|
||||||
|
describe('sanitizeSurrogates', () => {
|
||||||
|
it('returns surrogate-free strings unchanged', () => {
|
||||||
|
expect(sanitizeSurrogates('')).toBe('');
|
||||||
|
expect(sanitizeSurrogates('https://host/sub/uuid#remark')).toBe('https://host/sub/uuid#remark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps valid surrogate pairs intact', () => {
|
||||||
|
expect(sanitizeSurrogates('server 😀 name')).toBe('server 😀 name');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces a lone high surrogate', () => {
|
||||||
|
expect(sanitizeSurrogates('abc\uD83D')).toBe('abc<62>');
|
||||||
|
expect(sanitizeSurrogates('\uD83Dxyz')).toBe('<27>xyz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces a lone low surrogate', () => {
|
||||||
|
expect(sanitizeSurrogates('abc\uDE00def')).toBe('abc<62>def');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes a truncated-emoji string safe for encodeURI', () => {
|
||||||
|
const broken = 'remark \uD83D truncated';
|
||||||
|
expect(() => encodeURI(broken)).toThrow();
|
||||||
|
expect(() => encodeURI(sanitizeSurrogates(broken))).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
17
src/utils/validation.test.ts
Normal file
17
src/utils/validation.test.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isValidEmail } from './validation';
|
||||||
|
|
||||||
|
describe('isValidEmail', () => {
|
||||||
|
it('accepts common addresses', () => {
|
||||||
|
expect(isValidEmail('user@example.com')).toBe(true);
|
||||||
|
expect(isValidEmail('a.b+tag@sub.domain.io')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed addresses', () => {
|
||||||
|
expect(isValidEmail('')).toBe(false);
|
||||||
|
expect(isValidEmail('plain')).toBe(false);
|
||||||
|
expect(isValidEmail('user@host')).toBe(false);
|
||||||
|
expect(isValidEmail('user @example.com')).toBe(false);
|
||||||
|
expect(isValidEmail('user@exa mple.com')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,5 +8,5 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
10
vitest.config.ts
Normal file
10
vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
// Kept separate from vite.config.ts so the app build config stays untouched;
|
||||||
|
// utility tests run in a plain node environment (no jsdom needed).
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['src/**/*.test.{ts,tsx}'],
|
||||||
|
environment: 'node',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user