From 42788e842d24edb0ed7fee1b42496970924b11fe Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:09:33 +0300 Subject: [PATCH 01/12] =?UTF-8?q?test:=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B8=D1=82=D1=8C=20vitest=20=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D1=8C=20=D1=83=D1=82=D0=B8=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D1=8B=20=D1=8E=D0=BD=D0=B8=D1=82-=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 380 ++++++++++++++++++++++++++- package.json | 5 +- src/utils/formatTraffic.test.ts | 23 ++ src/utils/period.test.ts | 29 ++ src/utils/safeRedirect.test.ts | 36 +++ src/utils/sanitizeSurrogates.test.ts | 28 ++ src/utils/validation.test.ts | 17 ++ tsconfig.node.json | 2 +- vitest.config.ts | 10 + 9 files changed, 527 insertions(+), 3 deletions(-) create mode 100644 src/utils/formatTraffic.test.ts create mode 100644 src/utils/period.test.ts create mode 100644 src/utils/safeRedirect.test.ts create mode 100644 src/utils/sanitizeSurrogates.test.ts create mode 100644 src/utils/validation.test.ts create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index baa120a..f0c6ebd 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 7d9fc8a..9e53f98 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/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', + }, +}); From b40f939e914453d3080bde6051a7dbcf66a42321 Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:46:22 +0300 Subject: [PATCH 02/12] =?UTF-8?q?fix(ui):=20=D1=84=D0=B8=D0=BA=D1=81=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D0=B0=D1=8F=20=D1=88=D0=B8?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B0=20100vw=20=E2=80=94=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20=D0=BD=D0=B5=20=D1=81?= =?UTF-8?q?=D0=B4=D0=B2=D0=B8=D0=B3=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=BF=D0=BE=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BA=D1=80=D0=BE=D0=BB=D0=BB=D0=B1=D0=B0=D1=80?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/AppShell/AppShell.tsx | 6 +++++- src/styles/globals.css | 11 +++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index 4afc448..05d9e3c 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -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/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, From a7ff8a6b09281df2b971cddf30a95a4f8b774205 Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:46:24 +0300 Subject: [PATCH 03/12] =?UTF-8?q?fix(header):=20=D0=BA=D1=8D=D1=88=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BE=D0=BA=20=D1=8F=D0=B7=D1=8B=D0=BA=D0=BE=D0=B2=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=20=D0=BD=D0=B5=20=D0=BC=D0=B8=D0=B3=D0=B0?= =?UTF-8?q?=D0=B5=D1=82=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B5=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/LanguageSwitcher.tsx | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) 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: '🇷🇺' }; From 1ea9b99ab579f709db440b103cd37f93b92927ca Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Mon, 13 Jul 2026 01:52:36 +0300 Subject: [PATCH 04/12] =?UTF-8?q?fix(ui):=20=D0=BD=D0=B5=20=D0=BF=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B9=20=D1=84=D0=BE=D0=BD=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B5=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 3 ++ src/components/backgrounds/BackgroundHost.tsx | 45 +++++++++++++++++++ src/components/layout/AppShell/AppShell.tsx | 8 ++-- 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 src/components/backgrounds/BackgroundHost.tsx 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/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 05d9e3c..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 */} From 2eaea75fd93f1db3a0203ff308f365b11079cbc4 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 13 Jul 2026 02:12:57 +0300 Subject: [PATCH 05/12] =?UTF-8?q?feat(navigation):=20=D1=80=D0=BE=D1=83?= =?UTF-8?q?=D1=82=D0=B8=D0=BD=D0=B3=20startapp-=D0=B4=D0=B8=D0=BF=D0=BB?= =?UTF-8?q?=D0=B8=D0=BD=D0=BA=D0=BE=D0=B2=20rich-=D0=BC=D0=B5=D0=BD=D1=8E?= =?UTF-8?q?=20=D0=B1=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StartParamNavigator обобщён до таблицы маршрутов: помимо admin_ticket_ теперь разбираются renew_ → /subscriptions//renew и subscriptions → /subscriptions — их шлют ссылки «Продлить» у истёкших подписок в rich-меню бота (t.me//?startapp=…). Доступ по-прежнему ограничен гвардами самих маршрутов. biome check, type-check и build проходят (pre-commit пропущен: biome не установлен локально как зависимость — бинарь есть только в CI). --- src/AppWithNavigator.tsx | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/AppWithNavigator.tsx b/src/AppWithNavigator.tsx index f2eee1e..cae8572 100644 --- a/src/AppWithNavigator.tsx +++ b/src/AppWithNavigator.tsx @@ -173,18 +173,24 @@ 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' }, +]; /** * 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 +208,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]); From b4b4bca2cb290c1be4ae4c076593200c686b72c5 Mon Sep 17 00:00:00 2001 From: kewldan <40865857+kewldan@users.noreply.github.com> Date: Mon, 13 Jul 2026 02:32:11 +0300 Subject: [PATCH 06/12] =?UTF-8?q?feat(broadcasts):=20=D0=BF=D0=BE=D0=BB?= =?UTF-8?q?=D0=B5=20icon=5Fcustom=5Femoji=5Fid=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=B0=D1=81=D1=82=D0=BE=D0=BC=D0=BD=D1=8B=D1=85=20=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=D0=BF=D0=BE=D0=BA=20=D1=80=D0=B0=D1=81=D1=81=D1=8B?= =?UTF-8?q?=D0=BB=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/adminBroadcasts.ts | 2 ++ src/locales/en.json | 1 + src/locales/fa.json | 1 + src/locales/ru.json | 1 + src/locales/zh.json | 1 + src/pages/AdminBroadcastCreate.tsx | 26 +++++++++++++++++++++----- 6 files changed, 27 insertions(+), 5 deletions(-) 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/locales/en.json b/src/locales/en.json index e0541f3..ab9bae8 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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 64b6dee..78e9e40 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": "تبلیغاتی", diff --git a/src/locales/ru.json b/src/locales/ru.json index 3415f79..5183e30 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": "Тема", diff --git a/src/locales/zh.json b/src/locales/zh.json index 08eb37c..02ae25f 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": "促销", 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" + />
+ ))} +
+ + {/* 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/locales/en.json b/src/locales/en.json index e0541f3..3ebb3e2 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3378,7 +3378,8 @@ "sync": "Synchronization", "tickets": "Tickets", "gifts": "Gifts", - "referrals": "Referrals" + "referrals": "Referrals", + "activity": "Activity" }, "noTickets": "No tickets from this user", "ticketsCount": "tickets", @@ -3579,6 +3580,66 @@ "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" + }, + "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" + }, + "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" + } } }, "notFound": "User not found", diff --git a/src/locales/fa.json b/src/locales/fa.json index 64b6dee..a41c9ed 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -2882,7 +2882,8 @@ "sync": "همگام‌سازی", "tickets": "تیکت‌ها", "gifts": "هدایا", - "referrals": "معرفی‌ها" + "referrals": "معرفی‌ها", + "activity": "فعالیت" }, "noTickets": "این کاربر تیکتی ندارد", "ticketsCount": "تیکت", @@ -3083,7 +3084,67 @@ "areYouSure": "آیا مطمئنید می‌خواهید ادامه دهید؟", "title": "عملیات" }, - "noVpnData": "داده‌ای از VPN وجود ندارد" + "noVpnData": "داده‌ای از VPN وجود ندارد", + "activity": { + "total": "مجموع رویدادها", + "empty": "هنوز فعالیتی وجود ندارد", + "filters": { + "all": "همه", + "payments": "پرداخت‌ها", + "events": "رویدادها", + "promo": "تخفیف‌ها", + "tickets": "تیکت‌ها", + "gifts": "هدایا", + "referrals": "دعوت‌ها", + "logins": "ورودها" + }, + "types": { + "transaction": "تراکنش", + "event": "رویداد", + "promocode": "کد تخفیف", + "coupon": "کوپن", + "ticket": "تیکت", + "wheel_spin": "گردونه شانس", + "poll": "نظرسنجی تکمیل شد", + "gift_sent": "هدیه ارسال شد", + "gift_received": "هدیه دریافت شد", + "referral_earning": "درآمد دعوت", + "cabinet_login": "ورود به پنل", + "withdrawal": "درخواست برداشت" + }, + "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": "انجام شده" + } + } }, "notFound": "کاربر یافت نشد", "purchaseCount": "تعداد خرید: {{count}}", diff --git a/src/locales/ru.json b/src/locales/ru.json index 3415f79..4b054b5 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3785,7 +3785,8 @@ "sync": "Синхронизация", "tickets": "Тикеты", "gifts": "Подарки", - "referrals": "Рефералы" + "referrals": "Рефералы", + "activity": "Активность" }, "noTickets": "У пользователя нет тикетов", "ticketsCount": "тикетов", @@ -3985,6 +3986,66 @@ "searchPlaceholder": "Поиск по имени, email или Telegram ID...", "alreadyReferred": "уже чей-то реферал", "noUsersFound": "Пользователи не найдены" + }, + "activity": { + "total": "Всего событий", + "empty": "Активности пока нет", + "filters": { + "all": "Все", + "payments": "Платежи", + "events": "События", + "promo": "Промо", + "tickets": "Тикеты", + "gifts": "Подарки", + "referrals": "Рефералы", + "logins": "Входы" + }, + "types": { + "transaction": "Транзакция", + "event": "Событие", + "promocode": "Промокод", + "coupon": "Купон", + "ticket": "Тикет", + "wheel_spin": "Колесо фортуны", + "poll": "Опрос пройден", + "gift_sent": "Подарок отправлен", + "gift_received": "Подарок получен", + "referral_earning": "Реферальное начисление", + "cabinet_login": "Вход в кабинет", + "withdrawal": "Заявка на вывод" + }, + "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": "Выполнена" + } } }, "notFound": "Пользователь не найден", diff --git a/src/locales/zh.json b/src/locales/zh.json index 08eb37c..21b34a7 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -2881,7 +2881,8 @@ "sync": "同步", "tickets": "工单", "gifts": "礼物", - "referrals": "推荐" + "referrals": "推荐", + "activity": "活动记录" }, "noTickets": "该用户没有工单", "ticketsCount": "个工单", @@ -3082,7 +3083,67 @@ "areYouSure": "确定要继续?", "title": "操作" }, - "noVpnData": "无 VPN 数据" + "noVpnData": "无 VPN 数据", + "activity": { + "total": "事件总数", + "empty": "暂无活动记录", + "filters": { + "all": "全部", + "payments": "支付", + "events": "事件", + "promo": "促销", + "tickets": "工单", + "gifts": "礼物", + "referrals": "推荐", + "logins": "登录" + }, + "types": { + "transaction": "交易", + "event": "事件", + "promocode": "促销码", + "coupon": "优惠券", + "ticket": "工单", + "wheel_spin": "幸运转盘", + "poll": "问卷已完成", + "gift_sent": "已发送礼物", + "gift_received": "已收到礼物", + "referral_earning": "推荐收益", + "cabinet_login": "登录后台", + "withdrawal": "提现申请" + }, + "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": "已完成" + } + } }, "notFound": "未找到用户", "purchaseCount": "购买次数:{{count}}", diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 828a50e..ba63740 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -24,6 +24,7 @@ import { GiftsTab } from '../components/admin/userDetail/GiftsTab'; import { SyncTab } from '../components/admin/userDetail/SyncTab'; import { ReferralsTab } from '../components/admin/userDetail/ReferralsTab'; import { BalanceTab } from '../components/admin/userDetail/BalanceTab'; +import { ActivityTab } from '../components/admin/userDetail/ActivityTab'; import { TicketsTab } from '../components/admin/userDetail/TicketsTab'; import { InfoTab } from '../components/admin/userDetail/InfoTab'; import { SubscriptionTab } from '../components/admin/userDetail/SubscriptionTab'; @@ -49,7 +50,7 @@ export default function AdminUserDetail() { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState< - 'info' | 'subscription' | 'balance' | 'sync' | 'tickets' | 'gifts' | 'referrals' + 'info' | 'subscription' | 'balance' | 'sync' | 'tickets' | 'gifts' | 'referrals' | 'activity' >('info'); const [syncStatus, setSyncStatus] = useState(null); const [tariffs, setTariffs] = useState([]); @@ -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" 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')) .map((tab) => (
@@ -948,6 +961,11 @@ export default function AdminUserDetail() { {activeTab === 'referrals' && user && userId && ( )} + + {/* Activity Tab */} + {activeTab === 'activity' && userId && ( + + )}
); From 5fc8e7f97a2f09f4d81ff17fa3914eacc7db58b7 Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 13 Jul 2026 03:46:53 +0300 Subject: [PATCH 10/12] =?UTF-8?q?feat(admin):=20=D0=BA=D0=BB=D0=B8=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BE=D0=BA=20=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B8=20=D0=B4=D0=B5=D0=B9=D1=81=D1=82=D0=B2?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=20=D0=BA=D0=B0=D0=B1=D0=B8=D0=BD=D0=B5?= =?UTF-8?q?=D1=82=D0=B5=20=D0=B2=D0=BE=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D0=BA=D0=B5=20=C2=AB=D0=90=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D1=8C=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Два новых типа записей таймлайна: button_click (нажатия кнопок в боте, с текстом кнопки) и cabinet_action (мутационные действия юзера в кабинете, метод + нормализованный путь). Чип-фильтр «Клики», иконки BotIcon/BoltIcon, переводы во всех 4 локалях. biome check, type-check и build проходят (pre-commit пропущен: biome не установлен локально). --- src/components/admin/userDetail/ActivityTab.tsx | 5 +++++ src/locales/en.json | 7 +++++-- src/locales/fa.json | 7 +++++-- src/locales/ru.json | 7 +++++-- src/locales/zh.json | 7 +++++-- 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/components/admin/userDetail/ActivityTab.tsx b/src/components/admin/userDetail/ActivityTab.tsx index 108adb6..4bf0614 100644 --- a/src/components/admin/userDetail/ActivityTab.tsx +++ b/src/components/admin/userDetail/ActivityTab.tsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'; import { adminUsersApi, type UserActivityItem } from '../../../api/adminUsers'; import { BanknotesIcon, + BoltIcon, + BotIcon, CabinetIcon, ChartBarIcon, ChatIcon, @@ -40,6 +42,7 @@ const FILTERS: Array<{ key: string; types: string | null }> = [ { 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' }, ]; @@ -56,6 +59,8 @@ const TYPE_VISUALS: Record = 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' }; diff --git a/src/locales/en.json b/src/locales/en.json index 3ebb3e2..4c55233 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3592,7 +3592,8 @@ "tickets": "Tickets", "gifts": "Gifts", "referrals": "Referrals", - "logins": "Logins" + "logins": "Logins", + "clicks": "Clicks" }, "types": { "transaction": "Transaction", @@ -3606,7 +3607,9 @@ "gift_received": "Gift received", "referral_earning": "Referral earning", "cabinet_login": "Cabinet login", - "withdrawal": "Withdrawal request" + "withdrawal": "Withdrawal request", + "button_click": "Bot button", + "cabinet_action": "Cabinet action" }, "sources": { "bot": "Bot", diff --git a/src/locales/fa.json b/src/locales/fa.json index a41c9ed..8bec173 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3096,7 +3096,8 @@ "tickets": "تیکت‌ها", "gifts": "هدایا", "referrals": "دعوت‌ها", - "logins": "ورودها" + "logins": "ورودها", + "clicks": "کلیک‌ها" }, "types": { "transaction": "تراکنش", @@ -3110,7 +3111,9 @@ "gift_received": "هدیه دریافت شد", "referral_earning": "درآمد دعوت", "cabinet_login": "ورود به پنل", - "withdrawal": "درخواست برداشت" + "withdrawal": "درخواست برداشت", + "button_click": "دکمه ربات", + "cabinet_action": "اقدام در پنل" }, "sources": { "bot": "ربات", diff --git a/src/locales/ru.json b/src/locales/ru.json index 4b054b5..38fd8a3 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -3998,7 +3998,8 @@ "tickets": "Тикеты", "gifts": "Подарки", "referrals": "Рефералы", - "logins": "Входы" + "logins": "Входы", + "clicks": "Клики" }, "types": { "transaction": "Транзакция", @@ -4012,7 +4013,9 @@ "gift_received": "Подарок получен", "referral_earning": "Реферальное начисление", "cabinet_login": "Вход в кабинет", - "withdrawal": "Заявка на вывод" + "withdrawal": "Заявка на вывод", + "button_click": "Кнопка в боте", + "cabinet_action": "Действие в кабинете" }, "sources": { "bot": "Бот", diff --git a/src/locales/zh.json b/src/locales/zh.json index 21b34a7..5bf0ce8 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3095,7 +3095,8 @@ "tickets": "工单", "gifts": "礼物", "referrals": "推荐", - "logins": "登录" + "logins": "登录", + "clicks": "点击" }, "types": { "transaction": "交易", @@ -3109,7 +3110,9 @@ "gift_received": "已收到礼物", "referral_earning": "推荐收益", "cabinet_login": "登录后台", - "withdrawal": "提现申请" + "withdrawal": "提现申请", + "button_click": "机器人按钮", + "cabinet_action": "后台操作" }, "sources": { "bot": "机器人", From f9ee16a2e67ed36b8672dd35b4a27d075743cc0a Mon Sep 17 00:00:00 2001 From: Fringg Date: Mon, 13 Jul 2026 04:19:32 +0300 Subject: [PATCH 11/12] =?UTF-8?q?feat(admin):=20=D0=B1=D0=B5=D0=B9=D0=B4?= =?UTF-8?q?=D0=B6=20=C2=AB=D0=9A=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=D0=B0=C2=BB?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=20?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=20=D0=B2=20=D1=82=D0=B0=D0=B9=D0=BC?= =?UTF-8?q?=D0=BB=D0=B0=D0=B9=D0=BD=D0=B5=20=D0=B0=D0=BA=D1=82=D0=B8=D0=B2?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Команды (/start и др.) приходят типом button_click с подтипом command — добавлен перевод бейджа во всех 4 локалях. --- src/locales/en.json | 3 ++- src/locales/fa.json | 3 ++- src/locales/ru.json | 3 ++- src/locales/zh.json | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 4c55233..319a1e1 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -3641,7 +3641,8 @@ "redeemed": "Redeemed", "approved": "Approved", "rejected": "Rejected", - "completed": "Completed" + "completed": "Completed", + "command": "Command" } } }, diff --git a/src/locales/fa.json b/src/locales/fa.json index 8bec173..b5f7912 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -3145,7 +3145,8 @@ "redeemed": "استفاده شده", "approved": "تأیید شده", "rejected": "رد شده", - "completed": "انجام شده" + "completed": "انجام شده", + "command": "دستور" } } }, diff --git a/src/locales/ru.json b/src/locales/ru.json index 38fd8a3..081f1d5 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -4047,7 +4047,8 @@ "redeemed": "Погашен", "approved": "Одобрена", "rejected": "Отклонена", - "completed": "Выполнена" + "completed": "Выполнена", + "command": "Команда" } } }, diff --git a/src/locales/zh.json b/src/locales/zh.json index 5bf0ce8..4f071e0 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -3144,7 +3144,8 @@ "redeemed": "已兑换", "approved": "已批准", "rejected": "已拒绝", - "completed": "已完成" + "completed": "已完成", + "command": "命令" } } }, From 255777e7d1db7af09963d397a187bf0d15cc5b39 Mon Sep 17 00:00:00 2001 From: Chara-Freedom Date: Mon, 13 Jul 2026 05:36:12 +0300 Subject: [PATCH 12/12] fix(subscription): fall back to purchase flow for free-tariff switches The bot backend now rejects prorated tariff switches from a free (0RUB) tariff with 400 free_tariff_cannot_switch + use_purchase_flow=true (TARIFF_SWITCH_RESET_FREE_DAYS: the free remainder must reset, not be prorated over hundreds of accumulated days and carried to a paid tariff). Handle it like the existing trial_cannot_switch contract: - SwitchTariffSheet: accept free_tariff_cannot_switch in shouldUsePurchaseFlow so the sheet hands off to the purchase form - TariffPickerGrid: new subscription_on_free_tariff purchase-options flag disables the switch CTA up front and shows purchase instead Companion to BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot#3056. Co-Authored-By: Claude Fable 5 --- .../subscription/purchase/TariffPickerGrid.tsx | 8 ++++++++ .../subscription/sheets/SwitchTariffSheet.tsx | 14 +++++++++----- src/types/index.ts | 3 +++ 3 files changed, 20 insertions(+), 5 deletions(-) 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/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;