diff --git a/docker-compose.yml b/docker-compose.yml index 316684f..70802ca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,9 @@ services: # Внутри контейнера nginx слушает на порту 80 - '${CABINET_PORT:-3020}:80' 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 timeout: 3s retries: 3 diff --git a/nginx.conf b/nginx.conf index 1706b3b..868c49d 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,5 +1,9 @@ server { listen 80; + # Слушаем и IPv6: healthcheck ходит на localhost, который в контейнере + # резолвится в [::1] — без этой строки wget получает Connection refused + # и контейнер стабильно уходит в unhealthy (#3024 в репозитории бота). + listen [::]:80; server_name _; root /usr/share/nginx/html; diff --git a/package-lock.json b/package-lock.json index a2ab10e..a48e9a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,7 +78,8 @@ "postcss": "^8.4.31", "tailwindcss": "^3.4.19", "typescript": "^5.2.2", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.10" } }, "node_modules/@alloc/quick-lru": { @@ -3373,6 +3374,17 @@ "@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": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -3436,6 +3448,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "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": { "version": "3.0.5", "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" } }, + "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": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -3634,6 +3766,16 @@ "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": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3808,6 +3950,16 @@ ], "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": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -4267,6 +4419,13 @@ "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": { "version": "1.1.1", "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" } }, + "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": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", @@ -4382,6 +4551,16 @@ "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": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", @@ -5089,6 +5268,16 @@ "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": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", @@ -5285,6 +5474,20 @@ "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": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -5314,6 +5517,13 @@ "dev": true, "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6228,6 +6438,13 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "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": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sigma/-/sigma-3.0.2.tgz", @@ -6296,6 +6513,20 @@ "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": { "version": "0.3.2", "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==", "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": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6510,6 +6758,16 @@ "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": { "version": "5.0.1", "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" } }, + "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -6818,6 +7179,23 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "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": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", diff --git a/package.json b/package.json index 114d746..96e061d 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "format": "biome format --write .", "format:check": "biome format .", "check": "biome check .", + "test": "vitest run", + "test:watch": "vitest", "type-check": "tsc --noEmit", "preview": "vite preview", "prepare": "husky" @@ -87,7 +89,8 @@ "postcss": "^8.4.31", "tailwindcss": "^3.4.19", "typescript": "^5.2.2", - "vite": "^7.3.1" + "vite": "^7.3.1", + "vitest": "^4.1.10" }, "lint-staged": { "*.{ts,tsx,json}": [ diff --git a/src/App.tsx b/src/App.tsx index dfb426e..0a6d145 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import { ServiceUnavailableScreen, } from './components/blocking'; import { ErrorBoundary } from './components/ErrorBoundary'; +import { BackgroundHost } from './components/backgrounds/BackgroundHost'; import { PermissionRoute } from '@/components/auth/PermissionRoute'; import { saveReturnUrl } from './utils/token'; import { useAnalyticsCounters } from './hooks/useAnalyticsCounters'; @@ -259,6 +260,8 @@ function App() { return ( <> + {/* Живёт над : анимация фона не перезапускается при навигации */} + {/* Public routes */} diff --git a/src/AppWithNavigator.tsx b/src/AppWithNavigator.tsx index f2eee1e..f343fde 100644 --- a/src/AppWithNavigator.tsx +++ b/src/AppWithNavigator.tsx @@ -173,18 +173,27 @@ function TelegramBackButton() { return null; } -/** `admin_ticket_` startapp param → /admin/tickets/. */ -const ADMIN_TICKET_START_PARAM_RE = /^admin_ticket_(\d+)$/; +/** Supported startapp params → in-app destinations. */ +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. * - * Admin ticket notification buttons in GROUP/channel chats open the cabinet via - * a `t.me//?startapp=admin_ticket_` deep link (bot issue #2988) — - * `web_app` buttons don't work in group chats, so the startapp param is the only - * way in. Telegram delivers it as `tgWebAppStartParam`; we map it to the admin - * ticket route once on mount. Access is still gated by the route's - * `PermissionRoute permission="tickets:read"`. + * Text links and buttons outside private-chat web_app buttons can only enter the + * Mini App via `t.me//?startapp=` deep links: admin ticket + * notifications in GROUP/channel chats (bot issue #2988) and the bot's rich + * main-menu «Продлить» links for expired subscriptions. Telegram delivers the + * param as `tgWebAppStartParam`; we map it to a route once on mount. Access is + * still gated by each route's own guards (e.g. `PermissionRoute`). */ function StartParamNavigator() { const navigate = useNavigate(); @@ -202,9 +211,12 @@ function StartParamNavigator() { } if (!startParam) return; - const match = ADMIN_TICKET_START_PARAM_RE.exec(startParam); - if (match) { - navigate(`/admin/tickets/${match[1]}`, { replace: true }); + for (const { re, to } of START_PARAM_ROUTES) { + const match = re.exec(startParam); + if (match) { + navigate(to(match), { replace: true }); + return; + } } }, [navigate]); diff --git a/src/api/adminBroadcasts.ts b/src/api/adminBroadcasts.ts index dfc77ff..30c2dbe 100644 --- a/src/api/adminBroadcasts.ts +++ b/src/api/adminBroadcasts.ts @@ -52,6 +52,8 @@ export interface CustomBroadcastButton { label: string; action_type: 'callback' | 'url'; action_value: string; + /** Telegram custom emoji перед текстом кнопки (числовая строка custom_emoji_id) */ + icon_custom_emoji_id?: string; } export interface BroadcastMedia { diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index a3f0e74..bc9a053 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -173,6 +173,23 @@ export interface SubscriptionRequestHistory { records: SubscriptionRequestRecord[]; } +export interface UserActivityItem { + type: string; + subtype: string | null; + source: string | null; + title: string | null; + amount_kopeks: number | null; + timestamp: string; + meta: Record | null; +} + +export interface UserActivityResponse { + items: UserActivityItem[]; + total: number; + offset: number; + limit: number; +} + export interface UserNodeUsageItem { node_uuid: string; node_name: string; @@ -699,6 +716,19 @@ export const adminUsersApi = { return response.data; }, + // Unified activity timeline (bot + cabinet actions) + getUserActivity: async ( + userId: number, + offset = 0, + limit = 25, + types?: string, + ): Promise => { + const params: Record = { 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 getSubscriptionRequestHistory: async ( userId: number, diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx index 45585f3..47b95a7 100644 --- a/src/components/LanguageSwitcher.tsx +++ b/src/components/LanguageSwitcher.tsx @@ -1,25 +1,23 @@ import { useTranslation } from 'react-i18next'; 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'; export default function LanguageSwitcher() { const { i18n } = useTranslation(); const [isOpen, setIsOpen] = useState(false); - const [availableLanguages, setAvailableLanguages] = useState([]); const dropdownRef = useRef(null); - useEffect(() => { - const fetchLanguages = async () => { - try { - const data = await infoApi.getLanguages(); - setAvailableLanguages(data.languages); - } catch { - // Silently fall back to empty list — component handles it gracefully - } - }; - fetchLanguages(); - }, []); + // Кэш react-query переживает перемонтирование AppShell при смене роута: + // локальный useState начинал каждый маунт с пустого списка, и до ответа API + // компонент рендерил null — переключатель «мигал» при каждой навигации. + const { data } = useQuery({ + queryKey: ['languages'], + queryFn: infoApi.getLanguages, + staleTime: 1000 * 60 * 5, + }); + const availableLanguages = data?.languages ?? []; const currentLang = availableLanguages.find((l) => l.code === i18n.language) || availableLanguages[0] || { code: 'ru', name: 'RU', flag: '🇷🇺' }; diff --git a/src/components/admin/userDetail/ActivityTab.tsx b/src/components/admin/userDetail/ActivityTab.tsx new file mode 100644 index 0000000..4bf0614 --- /dev/null +++ b/src/components/admin/userDetail/ActivityTab.tsx @@ -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 = { + 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, +): 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 ( + + {label} + + ); +} + +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 ( + + {sign} + {rubles.toLocaleString('ru-RU', { maximumFractionDigits: 2 })} ₽ + + ); +} + +export function ActivityTab({ userId, formatDate }: ActivityTabProps) { + const { t } = useTranslation(); + + const [filter, setFilter] = useState('all'); + const [items, setItems] = useState([]); + 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 ( +
+ } + tone="accent" + loading={!initialLoaded && loading} + /> + + {/* Filter chips */} +
+ {FILTERS.map(({ key }) => ( + + ))} +
+ + {/* Timeline */} + {!initialLoaded && loading ? ( +
+
+
+ ) : items.length === 0 ? ( +
+ +

{t('admin.users.detail.activity.empty')}

+
+ ) : ( +
+ {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 ( +
+ {/* Icon column + connecting line */} +
+
+ +
+ {!isLast &&
} +
+ + {/* Content */} +
+
+
+ {typeLabel} + {item.subtype && } + {item.source && ( + + {t(`admin.users.detail.activity.sources.${item.source}`, { + defaultValue: item.source, + })} + + )} +
+ +
+ {item.title && ( +

+ {item.title.length > 160 ? `${item.title.slice(0, 160)}…` : item.title} +

+ )} +

+ {formatRelativeTime(item.timestamp, t)} +

+
+
+ ); + })} + + {/* Load more */} + {items.length < total && ( + + )} +
+ )} +
+ ); +} diff --git a/src/components/backgrounds/BackgroundHost.tsx b/src/components/backgrounds/BackgroundHost.tsx new file mode 100644 index 0000000..bc1c4ca --- /dev/null +++ b/src/components/backgrounds/BackgroundHost.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; +import { create } from 'zustand'; +import { BackgroundRenderer } from './BackgroundRenderer'; + +/** + * Держит анимированный фон смонтированным ПОВЕРХ смены роутов. + * + * Каждый роут оборачивается в собственный , поэтому AppShell + * перемонтируется при каждой навигации — если рендерить BackgroundRenderer + * прямо из AppShell, анимация фона стартует заново на каждой странице. + * + * Вместо этого AppShell лишь регистрирует своё присутствие через + * useBackgroundConsumer(), а BackgroundHost живёт в App (над , не + * перемонтируется) и держит один экземпляр 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 ? : null; +} diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index 4afc448..18188cb 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -39,7 +39,7 @@ import { import { MobileBottomNav } from './MobileBottomNav'; import { AppHeader } from './AppHeader'; -import { BackgroundRenderer } from '@/components/backgrounds/BackgroundRenderer'; +import { useBackgroundConsumer } from '@/components/backgrounds/BackgroundHost'; interface AppShellProps { children: React.ReactNode; @@ -60,6 +60,9 @@ export function AppShell({ children }: AppShellProps) { const { appName, logoLetter, hasCustomLogo, logoUrl } = useBranding(); const { referralEnabled, wheelEnabled, hasContests, hasPolls, giftEnabled } = useFeatureFlags(); useScrollRestoration(); + // Анимированный фон рендерит BackgroundHost в App (не перемонтируется при + // смене роута) — здесь только регистрируем, что на этом роуте он нужен. + useBackgroundConsumer(); // Theme toggle visibility const { data: enabledThemes } = useQuery({ @@ -180,9 +183,6 @@ export function AppShell({ children }: AppShellProps) { return (
- {/* Animated background renders via portal on document.body at z-index: -1 */} - - {/* Global components */} @@ -190,7 +190,11 @@ export function AppShell({ children }: AppShellProps) { {/* Desktop Header */} -
+ {/* w-screen вместо left-0 right-0: right-0 упирается в край вьюпорта БЕЗ + скроллбара, и капсула по центру прыгала бы на полширины скроллбара при + переходах между страницами со скроллом и без. 100vw даёт ту же ось + центрирования, что и у body (тоже 100vw). */} +
{/* 3-зонный grid: лого | капсула | действия. Колонки 1fr_auto_1fr держат капсулу строго по центру вьюпорта НЕЗАВИСИМО от ширины лого/действий, а действия — у правого края. Поэтому ничего не «скачет» при переходах diff --git a/src/components/subscription/purchase/TariffPickerGrid.tsx b/src/components/subscription/purchase/TariffPickerGrid.tsx index ee367fa..bcbd09f 100644 --- a/src/components/subscription/purchase/TariffPickerGrid.tsx +++ b/src/components/subscription/purchase/TariffPickerGrid.tsx @@ -137,6 +137,13 @@ export function TariffPickerGrid({ purchaseOptions && 'subscription_is_expired' in purchaseOptions && 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 = !isMultiTariff && subscription && @@ -144,6 +151,7 @@ export function TariffPickerGrid({ !isCurrentTariff && !subscription.is_trial && !isSubscriptionExpired && + !isOnFreeTariff && (subscription.is_active || subscription.is_limited); const isLegacySubscription = subscription && !subscription.is_trial && !subscription.tariff_id; diff --git a/src/components/subscription/sheets/SwitchTariffSheet.tsx b/src/components/subscription/sheets/SwitchTariffSheet.tsx index 8007a78..ffe0aa7 100644 --- a/src/components/subscription/sheets/SwitchTariffSheet.tsx +++ b/src/components/subscription/sheets/SwitchTariffSheet.tsx @@ -27,11 +27,13 @@ import type { Tariff } from '../../../types'; // ────────────────────────────────────────────────────────────────── // 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 -// (`trial_cannot_switch`, bug #629889). Both arrive as detail.code + -// use_purchase_flow=true; some payloads use the legacy `error_code` key, so we -// accept either. +// (`trial_cannot_switch`, bug #629889), or it sits on a free 0₽ tariff whose +// spammed/gifted remainder must reset rather than be prorated and carried +// (`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 { if (!(error instanceof AxiosError)) return false; const detail = error.response?.data?.detail as @@ -40,7 +42,9 @@ function shouldUsePurchaseFlow(error: unknown): boolean { if (!detail || typeof detail !== 'object') return false; const code = detail.code ?? detail.error_code; 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 ); } diff --git a/src/locales/en.json b/src/locales/en.json index e0541f3..160c585 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1947,6 +1947,7 @@ "customButtonTypeUrl": "URL", "customButtonCallbackPlaceholder": "callback_data (e.g. back_to_menu)", "customButtonUrlPlaceholder": "https://example.com", + "customButtonEmojiIdPlaceholder": "Custom emoji ID (optional)", "preview": "Preview", "previewEmpty": "— empty —", "emailSubjectLabel": "Subject", @@ -3378,7 +3379,8 @@ "sync": "Synchronization", "tickets": "Tickets", "gifts": "Gifts", - "referrals": "Referrals" + "referrals": "Referrals", + "activity": "Activity" }, "noTickets": "No tickets from this user", "ticketsCount": "tickets", @@ -3579,6 +3581,70 @@ "searchPlaceholder": "Search by name, email, or Telegram ID...", "alreadyReferred": "already has referrer", "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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 64b6dee..0e73d2a 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1580,6 +1580,7 @@ "customButtonTypeUrl": "لینک", "customButtonCallbackPlaceholder": "callback_data (مثلاً back_to_menu)", "customButtonUrlPlaceholder": "https://example.com", + "customButtonEmojiIdPlaceholder": "شناسه ایموجی سفارشی (اختیاری)", "category": "دسته‌بندی", "categoryNews": "اخبار", "categoryPromo": "تبلیغاتی", @@ -2882,7 +2883,8 @@ "sync": "همگام‌سازی", "tickets": "تیکت‌ها", "gifts": "هدایا", - "referrals": "معرفی‌ها" + "referrals": "معرفی‌ها", + "activity": "فعالیت" }, "noTickets": "این کاربر تیکتی ندارد", "ticketsCount": "تیکت", @@ -3083,7 +3085,71 @@ "areYouSure": "آیا مطمئنید می‌خواهید ادامه دهید؟", "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": "کاربر یافت نشد", "purchaseCount": "تعداد خرید: {{count}}", diff --git a/src/locales/ru.json b/src/locales/ru.json index 3415f79..79f6820 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1974,6 +1974,7 @@ "customButtonTypeUrl": "Ссылка", "customButtonCallbackPlaceholder": "callback_data (например, back_to_menu)", "customButtonUrlPlaceholder": "https://example.com", + "customButtonEmojiIdPlaceholder": "Custom emoji ID (необязательно)", "preview": "Предпросмотр", "previewEmpty": "— пусто —", "emailSubjectLabel": "Тема", @@ -3785,7 +3786,8 @@ "sync": "Синхронизация", "tickets": "Тикеты", "gifts": "Подарки", - "referrals": "Рефералы" + "referrals": "Рефералы", + "activity": "Активность" }, "noTickets": "У пользователя нет тикетов", "ticketsCount": "тикетов", @@ -3985,6 +3987,70 @@ "searchPlaceholder": "Поиск по имени, email или Telegram ID...", "alreadyReferred": "уже чей-то реферал", "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": "Пользователь не найден", diff --git a/src/locales/zh.json b/src/locales/zh.json index 08eb37c..7a4281f 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1681,6 +1681,7 @@ "customButtonTypeUrl": "链接", "customButtonCallbackPlaceholder": "callback_data(例如 back_to_menu)", "customButtonUrlPlaceholder": "https://example.com", + "customButtonEmojiIdPlaceholder": "自定义 Emoji ID(可选)", "category": "类别", "categoryNews": "新闻", "categoryPromo": "促销", @@ -2881,7 +2882,8 @@ "sync": "同步", "tickets": "工单", "gifts": "礼物", - "referrals": "推荐" + "referrals": "推荐", + "activity": "活动记录" }, "noTickets": "该用户没有工单", "ticketsCount": "个工单", @@ -3082,7 +3084,71 @@ "areYouSure": "确定要继续?", "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": "未找到用户", "purchaseCount": "购买次数:{{count}}", diff --git a/src/pages/AdminBroadcastCreate.tsx b/src/pages/AdminBroadcastCreate.tsx index 529eb5e..8220b17 100644 --- a/src/pages/AdminBroadcastCreate.tsx +++ b/src/pages/AdminBroadcastCreate.tsx @@ -4,10 +4,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminBroadcastsApi, - BroadcastFilter, - TariffFilter, - CombinedBroadcastCreateRequest, - CustomBroadcastButton, + type BroadcastFilter, + type TariffFilter, + type CombinedBroadcastCreateRequest, + type CustomBroadcastButton, } from '../api/adminBroadcasts'; import { AdminBackButton } from '../components/admin'; import { TelegramPreview, EmailPreview } from '../components/broadcasts/BroadcastPreview'; @@ -63,6 +63,7 @@ export default function AdminBroadcastCreate() { const [newButtonLabel, setNewButtonLabel] = useState(''); const [newButtonActionType, setNewButtonActionType] = useState<'callback' | 'url'>('callback'); const [newButtonActionValue, setNewButtonActionValue] = useState(''); + const [newButtonEmojiId, setNewButtonEmojiId] = useState(''); const [mediaFile, setMediaFile] = useState(null); const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo'); const [mediaPreview, setMediaPreview] = useState(null); @@ -305,6 +306,8 @@ export default function AdminBroadcastCreate() { // Custom button validation const isNewButtonValid = useMemo(() => { 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') { return /^https:\/\/|^tg:\/\//.test(newButtonActionValue.trim()); } @@ -312,22 +315,25 @@ export default function AdminBroadcastCreate() { return new TextEncoder().encode(newButtonActionValue.trim()).length <= 64; } return true; - }, [newButtonLabel, newButtonActionType, newButtonActionValue]); + }, [newButtonLabel, newButtonActionType, newButtonActionValue, newButtonEmojiId]); // Custom button handlers const addCustomButton = () => { if (!isNewButtonValid) return; + const emojiId = newButtonEmojiId.trim(); setCustomButtons((prev) => [ ...prev, { label: newButtonLabel.trim(), action_type: newButtonActionType, action_value: newButtonActionValue.trim(), + ...(emojiId ? { icon_custom_emoji_id: emojiId } : {}), }, ]); setNewButtonLabel(''); setNewButtonActionValue(''); setNewButtonActionType('callback'); + setNewButtonEmojiId(''); setIsAddingCustomButton(false); }; @@ -800,6 +806,15 @@ export default function AdminBroadcastCreate() { maxLength={newButtonActionType === 'callback' ? 64 : 256} className="input" /> + setNewButtonEmojiId(e.target.value)} + placeholder={t('admin.broadcasts.customButtonEmojiIdPlaceholder')} + maxLength={64} + className="input" + />
@@ -948,6 +961,11 @@ export default function AdminUserDetail() { {activeTab === 'referrals' && user && userId && ( )} + + {/* Activity Tab */} + {activeTab === 'activity' && userId && ( + + )}
); diff --git a/src/styles/globals.css b/src/styles/globals.css index 2cbdb86..1c5bde0 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -235,6 +235,17 @@ 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) */ @media (max-width: 1023px) { html, diff --git a/src/types/index.ts b/src/types/index.ts index f4fd660..5e3deb2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -364,6 +364,9 @@ export interface TariffsPurchaseOptions { // New fields for expired subscription handling subscription_status?: string; 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; // Multi-tariff: all available tariffs already purchased all_tariffs_purchased?: boolean; diff --git a/src/utils/formatTraffic.test.ts b/src/utils/formatTraffic.test.ts new file mode 100644 index 0000000..659a69d --- /dev/null +++ b/src/utils/formatTraffic.test.ts @@ -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'); + }); +}); diff --git a/src/utils/period.test.ts b/src/utils/period.test.ts new file mode 100644 index 0000000..fbb0692 --- /dev/null +++ b/src/utils/period.test.ts @@ -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); + }); +}); diff --git a/src/utils/safeRedirect.test.ts b/src/utils/safeRedirect.test.ts new file mode 100644 index 0000000..e368483 --- /dev/null +++ b/src/utils/safeRedirect.test.ts @@ -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('/'); + }); +}); diff --git a/src/utils/sanitizeSurrogates.test.ts b/src/utils/sanitizeSurrogates.test.ts new file mode 100644 index 0000000..cebbd78 --- /dev/null +++ b/src/utils/sanitizeSurrogates.test.ts @@ -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�'); + expect(sanitizeSurrogates('\uD83Dxyz')).toBe('�xyz'); + }); + + it('replaces a lone low surrogate', () => { + expect(sanitizeSurrogates('abc\uDE00def')).toBe('abc�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(); + }); +}); diff --git a/src/utils/validation.test.ts b/src/utils/validation.test.ts new file mode 100644 index 0000000..501418b --- /dev/null +++ b/src/utils/validation.test.ts @@ -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); + }); +}); diff --git a/tsconfig.node.json b/tsconfig.node.json index ec78806..b6af3e1 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -8,5 +8,5 @@ "resolveJsonModule": true, "types": ["node"] }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..fc838d3 --- /dev/null +++ b/vitest.config.ts @@ -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', + }, +});