added english language

This commit is contained in:
SlimusMinus
2026-07-09 00:09:45 +03:00
parent 3b503623f6
commit fffcdaa526
33 changed files with 454 additions and 687 deletions

474
package-lock.json generated
View File

@@ -22,12 +22,15 @@
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"i18next": "^22.5.1",
"lucide-react": "^1.11.0", "lucide-react": "^1.11.0",
"postcss": "^8.5.12", "postcss": "^8.5.12",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.74.0", "react-hook-form": "^7.74.0",
"react-i18next": "^12.3.1",
"react-router-dom": "^7.14.2", "react-router-dom": "^7.14.2",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"sockjs-client": "^1.6.1", "sockjs-client": "^1.6.1",
@@ -3037,16 +3040,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@oxc-project/types": {
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@pbe/react-yandex-maps": { "node_modules/@pbe/react-yandex-maps": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/@pbe/react-yandex-maps/-/react-yandex-maps-1.2.5.tgz", "resolved": "https://registry.npmjs.org/@pbe/react-yandex-maps/-/react-yandex-maps-1.2.5.tgz",
@@ -3110,270 +3103,6 @@
} }
} }
}, },
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
"license": "MIT",
"peer": true
},
"node_modules/@rollup/plugin-babel": { "node_modules/@rollup/plugin-babel": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -7556,8 +7285,7 @@
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz", "resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==", "integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
@@ -9695,6 +9423,15 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/html-webpack-plugin": { "node_modules/html-webpack-plugin": {
"version": "5.6.7", "version": "5.6.7",
"resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.7.tgz", "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.7.tgz",
@@ -9852,6 +9589,29 @@
"node": ">=10.17.0" "node": ">=10.17.0"
} }
}, },
"node_modules/i18next": {
"version": "22.5.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-22.5.1.tgz",
"integrity": "sha512-8TGPgM3pAD+VRsMtUMNknRz3kzqwp/gPALrWMsDnmC1mKqJwpWyooQRLMcbTwq8z8YwSmuj+ZYvc+xCuEpkssA==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.6"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -14873,6 +14633,28 @@
"react": "^16.8.0 || ^17 || ^18 || ^19" "react": "^16.8.0 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-i18next": {
"version": "12.3.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-12.3.1.tgz",
"integrity": "sha512-5v8E2XjZDFzK7K87eSwC7AJcAkcLt5xYZ4+yTPDAW1i7C93oOY1dnr4BaQM7un4Hm+GmghuiPvevWwlca5PwDA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.20.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 19.0.0",
"react": ">= 16.8.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "17.0.2", "version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -15468,40 +15250,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/rolldown": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "=0.127.0",
"@rolldown/pluginutils": "1.0.0-rc.17"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "2.80.0", "version": "2.80.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
@@ -17346,20 +17094,6 @@
"is-typedarray": "^1.0.0" "is-typedarray": "^1.0.0"
} }
}, },
"node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"node_modules/unbox-primitive": { "node_modules/unbox-primitive": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -17599,95 +17333,13 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/vite": { "node_modules/void-elements": {
"version": "8.0.10", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.17",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": ">=0.10.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/w3c-hr-time": { "node_modules/w3c-hr-time": {

View File

@@ -17,12 +17,15 @@
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"i18next": "^22.5.1",
"lucide-react": "^1.11.0", "lucide-react": "^1.11.0",
"postcss": "^8.5.12", "postcss": "^8.5.12",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.74.0", "react-hook-form": "^7.74.0",
"react-i18next": "^12.3.1",
"react-router-dom": "^7.14.2", "react-router-dom": "^7.14.2",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"sockjs-client": "^1.6.1", "sockjs-client": "^1.6.1",

View File

@@ -1,4 +1,5 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { useTranslation } from 'react-i18next';
import CloseIcon from "./icons/CloseIcon"; import CloseIcon from "./icons/CloseIcon";
import { useAuthContext } from "../context/AuthContext"; import { useAuthContext } from "../context/AuthContext";
import { API_URL } from "../config/authConstants"; import { API_URL } from "../config/authConstants";
@@ -8,6 +9,7 @@ import { API_URL } from "../config/authConstants";
* Открывается когда неавторизованный пользователь пытается создать объявление. * Открывается когда неавторизованный пользователь пытается создать объявление.
*/ */
export default function AuthModal({ onClose }) { export default function AuthModal({ onClose }) {
const { t } = useTranslation();
const [mode, setMode] = useState("login"); // "login" | "register" const [mode, setMode] = useState("login"); // "login" | "register"
const { login: saveAuth } = useAuthContext(); const { login: saveAuth } = useAuthContext();
@@ -17,7 +19,7 @@ export default function AuthModal({ onClose }) {
{/* Header */} {/* Header */}
<div className="f-modal-head"> <div className="f-modal-head">
<span className="f-modal-title"> <span className="f-modal-title">
{mode === "login" ? "Вход в аккаунт" : "Регистрация"} {mode === "login" ? t('auth.loginModalTitle') : t('auth.registerModalTitle')}
</span> </span>
<button className="f-close-btn" onClick={onClose}> <button className="f-close-btn" onClick={onClose}>
<CloseIcon /> <CloseIcon />
@@ -43,13 +45,14 @@ export default function AuthModal({ onClose }) {
// ─── Login form (inline modal) ────────────────────────────────────────────── // ─── Login form (inline modal) ──────────────────────────────────────────────
function LoginFormInline({ saveAuth, onClose, onSwitch }) { function LoginFormInline({ saveAuth, onClose, onSwitch }) {
const { t } = useTranslation();
const [login, setLogin] = useState(""); const [login, setLogin] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const handleSubmit = async () => { const handleSubmit = async () => {
if (!login || !password) { setError("Введите логин и пароль"); return; } if (!login || !password) { setError(t('auth.enterLoginPassword')); return; }
setLoading(true); setError(""); setLoading(true); setError("");
try { try {
@@ -61,7 +64,7 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
throw new Error(data.message || "Неверный логин или пароль"); throw new Error(data.message || t('auth.invalidCredentials'));
} }
const { token, user } = await res.json(); const { token, user } = await res.json();
@@ -77,11 +80,11 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
return ( return (
<> <>
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Логин</label> <label className="f-m-label">{t('auth.loginLabel')}</label>
<input <input
className="f-m-field" className="f-m-field"
type="text" type="text"
placeholder="Ваш логин" placeholder={t('auth.yourLogin')}
value={login} value={login}
onChange={e => { setLogin(e.target.value); setError(""); }} onChange={e => { setLogin(e.target.value); setError(""); }}
autoComplete="off" autoComplete="off"
@@ -89,11 +92,11 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
</div> </div>
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Пароль</label> <label className="f-m-label">{t('auth.passwordLabel')}</label>
<input <input
className="f-m-field" className="f-m-field"
type="password" type="password"
placeholder="Ваш пароль" placeholder={t('auth.yourPassword')}
value={password} value={password}
onChange={e => { setPassword(e.target.value); setError(""); }} onChange={e => { setPassword(e.target.value); setError(""); }}
onKeyDown={e => e.key === "Enter" && handleSubmit()} onKeyDown={e => e.key === "Enter" && handleSubmit()}
@@ -104,7 +107,7 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
<SubmitButton <SubmitButton
loading={loading} loading={loading}
label="Войти" label={t('auth.login')}
onClick={handleSubmit} onClick={handleSubmit}
/> />
@@ -112,7 +115,7 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
textAlign: "center", marginTop: 14, textAlign: "center", marginTop: 14,
color: "rgba(255,255,255,.45)", fontSize: ".85rem" color: "rgba(255,255,255,.45)", fontSize: ".85rem"
}}> }}>
Нет аккаунта?{" "} {t('auth.noAccount')}{" "}
<button <button
onClick={onSwitch} onClick={onSwitch}
style={{ style={{
@@ -121,7 +124,7 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
fontFamily: "var(--font)", fontFamily: "var(--font)",
}} }}
> >
Зарегистрироваться {t('auth.register')}
</button> </button>
</p> </p>
</> </>
@@ -130,6 +133,7 @@ function LoginFormInline({ saveAuth, onClose, onSwitch }) {
// ─── Register form (inline modal) ────────────────────────────────────────── // ─── Register form (inline modal) ──────────────────────────────────────────
function RegisterFormInline({ saveAuth, onClose, onSwitch }) { function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
const { t } = useTranslation();
const [form, setForm] = useState({ const [form, setForm] = useState({
name: "", login: "", password: "", password2: "", name: "", login: "", password: "", password2: "",
}); });
@@ -144,11 +148,11 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
const validate = () => { const validate = () => {
const e = {}; const e = {};
if (!form.name.trim()) e.name = "Введите имя"; if (!form.name.trim()) e.name = t('auth.validateNameRequired');
if (!form.login.trim()) e.login = "Введите логин"; if (!form.login.trim()) e.login = t('auth.validateLoginRequired');
else if (form.login.length < 3) e.login = "Минимум 3 символа"; else if (form.login.length < 3) e.login = t('auth.loginMinChars');
if (form.password.length < 6) e.password = "Минимум 6 символов"; if (form.password.length < 6) e.password = t('auth.validatePasswordMin');
if (form.password !== form.password2) e.password2 = "Пароли не совпадают"; if (form.password !== form.password2) e.password2 = t('auth.validatePasswordMatch');
setErrors(e); setErrors(e);
return !Object.keys(e).length; return !Object.keys(e).length;
}; };
@@ -170,7 +174,7 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
}); });
const regData = await regRes.json(); const regData = await regRes.json();
if (!regRes.ok) throw new Error(regData.message || "Ошибка регистрации"); if (!regRes.ok) throw new Error(regData.message || t('auth.registrationError'));
// 2. Автоматический вход после регистрации // 2. Автоматический вход после регистрации
const loginRes = await fetch(`${API_URL}/api/v1/auth/login`, { const loginRes = await fetch(`${API_URL}/api/v1/auth/login`, {
@@ -194,11 +198,11 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
return ( return (
<> <>
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Имя</label> <label className="f-m-label">{t('auth.nameLabel')}</label>
<input <input
className={`f-m-field${errors.name ? " err" : ""}`} className={`f-m-field${errors.name ? " err" : ""}`}
type="text" type="text"
placeholder="Как вас зовут?" placeholder={t('auth.namePlaceholder')}
value={form.name} value={form.name}
onChange={e => setField("name", e.target.value)} onChange={e => setField("name", e.target.value)}
/> />
@@ -206,11 +210,11 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
</div> </div>
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Логин</label> <label className="f-m-label">{t('auth.loginLabel')}</label>
<input <input
className={`f-m-field${errors.login ? " err" : ""}`} className={`f-m-field${errors.login ? " err" : ""}`}
type="text" type="text"
placeholder="Придумайте логин" placeholder={t('auth.loginPlaceholder')}
value={form.login} value={form.login}
onChange={e => setField("login", e.target.value)} onChange={e => setField("login", e.target.value)}
/> />
@@ -219,22 +223,22 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
<div className="f-m-row"> <div className="f-m-row">
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Пароль</label> <label className="f-m-label">{t('auth.passwordLabel')}</label>
<input <input
className={`f-m-field${errors.password ? " err" : ""}`} className={`f-m-field${errors.password ? " err" : ""}`}
type="password" type="password"
placeholder="Мин. 6 символов" placeholder={t('auth.passwordMinShort')}
value={form.password} value={form.password}
onChange={e => setField("password", e.target.value)} onChange={e => setField("password", e.target.value)}
/> />
{errors.password && <p className="f-err-msg">{errors.password}</p>} {errors.password && <p className="f-err-msg">{errors.password}</p>}
</div> </div>
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Повторите</label> <label className="f-m-label">{t('auth.repeatLabel')}</label>
<input <input
className={`f-m-field${errors.password2 ? " err" : ""}`} className={`f-m-field${errors.password2 ? " err" : ""}`}
type="password" type="password"
placeholder="Ещё раз" placeholder={t('auth.repeatPlaceholder')}
value={form.password2} value={form.password2}
onChange={e => setField("password2", e.target.value)} onChange={e => setField("password2", e.target.value)}
/> />
@@ -246,7 +250,7 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
<SubmitButton <SubmitButton
loading={loading} loading={loading}
label="Создать аккаунт" label={t('auth.createAccountBtn').replace(' ✓', '')}
onClick={handleSubmit} onClick={handleSubmit}
/> />
@@ -254,7 +258,7 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
textAlign: "center", marginTop: 14, textAlign: "center", marginTop: 14,
color: "rgba(255,255,255,.45)", fontSize: ".85rem" color: "rgba(255,255,255,.45)", fontSize: ".85rem"
}}> }}>
Уже есть аккаунт?{" "} {t('auth.hasAccount')}{" "}
<button <button
onClick={onSwitch} onClick={onSwitch}
style={{ style={{
@@ -263,7 +267,7 @@ function RegisterFormInline({ saveAuth, onClose, onSwitch }) {
fontFamily: "var(--font)", fontFamily: "var(--font)",
}} }}
> >
Войти {t('auth.loginLink')}
</button> </button>
</p> </p>
</> </>

View File

@@ -1,4 +1,5 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { useTranslation } from 'react-i18next';
import MagnifierLogo from "./MagnifierLogo"; import MagnifierLogo from "./MagnifierLogo";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { useRegister } from "../hooks/useRegister"; import { useRegister } from "../hooks/useRegister";
@@ -9,14 +10,14 @@ import likeSmile from '../assets/success.png';
import { useCheckLogin } from "../hooks/useRegister"; import { useCheckLogin } from "../hooks/useRegister";
// ─── helpers ────────────────────────────────────────────────────────────────── // ─── helpers ──────────────────────────────────────────────────────────────────
function getStrength(val) { function getStrength(val, t) {
if (!val) return { level: 0, label: "", color: "transparent", cls: "" }; if (!val) return { level: 0, label: "", color: "transparent", cls: "" };
let s = 0; let s = 0;
if (val.length >= 6) s++; if (val.length >= 6) s++;
if (val.length >= 10) s++; if (val.length >= 10) s++;
if (/[A-Z]/.test(val) || /[0-9]/.test(val)) s++; if (/[A-Z]/.test(val) || /[0-9]/.test(val)) s++;
if (/[^A-Za-z0-9]/.test(val)) s++; if (/[^A-Za-z0-9]/.test(val)) s++;
const labels = ["", "Слабый", "Средний", "Хороший", "Отличный"]; const labels = ["", t('auth.strengthWeak'), t('auth.strengthMedium'), t('auth.strengthGood'), t('auth.strengthExcellent')];
const colors = ["", "#ff6b6b", "#ffd166", "#2de0c8", "#2de0c8"]; const colors = ["", "#ff6b6b", "#ffd166", "#2de0c8", "#2de0c8"];
const cls = ["", "sw", "sm", "ss", "ss"]; const cls = ["", "sw", "sm", "ss", "ss"];
return { level: s, label: labels[s], color: colors[s], cls: cls[s] }; return { level: s, label: labels[s], color: colors[s], cls: cls[s] };
@@ -49,6 +50,7 @@ function StepDots({ current, total }) {
// LOGIN FORM // LOGIN FORM
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
function LoginForm({ onSwitchToRegister }) { function LoginForm({ onSwitchToRegister }) {
const { t } = useTranslation();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPass, setShowPass] = useState(false); const [showPass, setShowPass] = useState(false);
@@ -56,14 +58,14 @@ function LoginForm({ onSwitchToRegister }) {
return ( return (
<div className="form-body"> <div className="form-body">
<p className="form-subtitle">С возвращением</p> <p className="form-subtitle">{t('auth.welcomeBack')}</p>
<form onSubmit={(e) => { e.preventDefault(); login(email, password); }} noValidate> <form onSubmit={(e) => { e.preventDefault(); login(email, password); }} noValidate>
<div className="field-wrap"> <div className="field-wrap">
<input <input
className="field" className="field"
type="email" type="email"
placeholder="Введите email" placeholder={t('auth.enterEmail')}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
autoComplete="off" autoComplete="off"
@@ -76,7 +78,7 @@ function LoginForm({ onSwitchToRegister }) {
<input <input
className="field" className="field"
type={showPass ? "text" : "password"} type={showPass ? "text" : "password"}
placeholder="Введите пароль" placeholder={t('auth.enterPassword')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
style={{ paddingRight: "44px" }} style={{ paddingRight: "44px" }}
@@ -90,16 +92,16 @@ function LoginForm({ onSwitchToRegister }) {
{error && <p className="err-msg">{error}</p>} {error && <p className="err-msg">{error}</p>}
<button type="submit" className="btn-primary" disabled={loading}> <button type="submit" className="btn-primary" disabled={loading}>
{loading ? "Входим..." : "Войти"} {loading ? t('auth.loggingIn') : t('auth.login')}
</button> </button>
</form> </form>
<button className="ghost-btn">Забыли пароль?</button> <button className="ghost-btn">{t('auth.forgotPassword')}</button>
<p className="switch-row"> <p className="switch-row">
Нет аккаунта?{" "} {t('auth.noAccount')}{" "}
<button className="link-btn" onClick={onSwitchToRegister}> <button className="link-btn" onClick={onSwitchToRegister}>
Зарегистрироваться {t('auth.register')}
</button> </button>
</p> </p>
</div> </div>
@@ -108,21 +110,22 @@ function LoginForm({ onSwitchToRegister }) {
// ─── step 0+1: name + login + passwords ─────────────────────────────────────── // ─── step 0+1: name + login + passwords ───────────────────────────────────────
function RegStep01({ data, onChange, onNext }) { function RegStep01({ data, onChange, onNext }) {
const { t } = useTranslation();
const [show1, setShow1] = useState(false); const [show1, setShow1] = useState(false);
const [show2, setShow2] = useState(false); const [show2, setShow2] = useState(false);
const [errors, setErrors] = useState({}); const [errors, setErrors] = useState({});
const strength = getStrength(data.password); const strength = getStrength(data.password, t);
const matched = data.password && data.password === data.password2; const matched = data.password && data.password === data.password2;
const { checking: checkingLogin, exists: loginExists } = useCheckLogin(data.login); const { checking: checkingLogin, exists: loginExists } = useCheckLogin(data.login);
const validate = () => { const validate = () => {
const e = {}; const e = {};
if (!data.name.trim()) e.name = "Введите имя"; if (!data.name.trim()) e.name = t('auth.validateNameRequired');
if (!data.login.trim()) e.login = "Введите логин"; if (!data.login.trim()) e.login = t('auth.validateLoginRequired');
else if (data.login.length < 3) e.login = "Минимум 3 символа"; else if (data.login.length < 3) e.login = t('auth.loginMinChars');
if (data.password.length < 6) e.password = "Минимум 6 символов"; if (data.password.length < 6) e.password = t('auth.validatePasswordMin');
else if (data.password !== data.password2) e.password2 = "Пароли не совпадают"; else if (data.password !== data.password2) e.password2 = t('auth.validatePasswordMatch');
setErrors(e); setErrors(e);
return !Object.keys(e).length; return !Object.keys(e).length;
@@ -131,11 +134,11 @@ function RegStep01({ data, onChange, onNext }) {
return ( return (
<> <>
<div className="field-wrap"> <div className="field-wrap">
<label className="field-label">Имя пользователя</label> <label className="field-label">{t('auth.username')}</label>
<input <input
className={`field ${errors.name ? "field-err" : ""}`} className={`field ${errors.name ? "field-err" : ""}`}
type="text" type="text"
placeholder="Как вас зовут?" placeholder={t('auth.namePlaceholder')}
value={data.name} value={data.name}
onChange={(e) => { onChange={(e) => {
onChange("name", e.target.value); onChange("name", e.target.value);
@@ -147,12 +150,12 @@ function RegStep01({ data, onChange, onNext }) {
</div> </div>
<div className="field-wrap"> <div className="field-wrap">
<label className="field-label">Логин</label> <label className="field-label">{t('auth.loginLabel')}</label>
<input <input
className={`field ${errors.login || loginExists ? "field-err" className={`field ${errors.login || loginExists ? "field-err"
: ""}`} : ""}`}
type="text" type="text"
placeholder="Придумайте логин" placeholder={t('auth.loginPlaceholder')}
value={data.login} value={data.login}
onChange={(e) => { onChange={(e) => {
onChange("login", e.target.value); onChange("login", e.target.value);
@@ -161,12 +164,12 @@ function RegStep01({ data, onChange, onNext }) {
autoComplete="off" autoComplete="off"
/> />
{checkingLogin && <p className="hint-msg">Проверяем...</p>} {checkingLogin && <p className="hint-msg">{t('auth.checking')}</p>}
{!checkingLogin && data.login.length >= 3 && ( {!checkingLogin && data.login.length >= 3 && (
<p className="err-msg" <p className="err-msg"
style={{color: loginExists ? "#ff6b6b" : "#2de0c8"}}> style={{color: loginExists ? "#ff6b6b" : "#2de0c8"}}>
{loginExists ? "✗ Логин занят" : "✓ Логин свободен"} {loginExists ? t('auth.loginTaken') : t('auth.loginFree')}
</p> </p>
)} )}
@@ -174,13 +177,13 @@ function RegStep01({ data, onChange, onNext }) {
</div> </div>
<div className="field-wrap"> <div className="field-wrap">
<label className="field-label">Пароль</label> <label className="field-label">{t('auth.passwordLabel')}</label>
<div className="input-wrap"> <div className="input-wrap">
<input <input
className={`field ${errors.password ? "field-err" : ""}`} className={`field ${errors.password ? "field-err" : ""}`}
type={show1 ? "text" : "password"} type={show1 ? "text" : "password"}
placeholder="Минимум 6 символов" placeholder={t('auth.passwordMin')}
value={data.password} value={data.password}
onChange={(e) => { onChange={(e) => {
onChange("password", e.target.value); onChange("password", e.target.value);
@@ -211,14 +214,14 @@ function RegStep01({ data, onChange, onNext }) {
<div className="field-wrap"> <div className="field-wrap">
<label className="field-label">Повторите пароль</label> <label className="field-label">{t('auth.repeatPassword')}</label>
<div className="input-wrap"> <div className="input-wrap">
<input <input
className={`field ${data.password2 ? (matched ? "field-ok" className={`field ${data.password2 ? (matched ? "field-ok"
: "field-err") : ""}`} : "field-err") : ""}`}
type={show2 ? "text" : "password"} type={show2 ? "text" : "password"}
placeholder="Введите ещё раз" placeholder={t('auth.repeatPasswordPlaceholder')}
value={data.password2} value={data.password2}
onChange={(e) => { onChange={(e) => {
onChange("password2", e.target.value); onChange("password2", e.target.value);
@@ -232,7 +235,7 @@ function RegStep01({ data, onChange, onNext }) {
{data.password2 && ( {data.password2 && (
<p className="err-msg" <p className="err-msg"
style={{color: matched ? "#2de0c8" : "#ff6b6b"}}> style={{color: matched ? "#2de0c8" : "#ff6b6b"}}>
{matched ? "✓ Совпадают" : "✗ Не совпадают"} {matched ? t('auth.passwordsMatch') : t('auth.passwordsNoMatch')}
</p> </p>
)} )}
@@ -240,7 +243,7 @@ function RegStep01({ data, onChange, onNext }) {
</div> </div>
<button className="btn-primary" onClick={() => validate() && onNext()}> <button className="btn-primary" onClick={() => validate() && onNext()}>
Далее → {t('auth.next')}
</button> </button>
</> </>
); );
@@ -248,16 +251,17 @@ function RegStep01({ data, onChange, onNext }) {
// ─── step 2: confirm ────────────────────────────────────────────────────────── // ─── step 2: confirm ──────────────────────────────────────────────────────────
function RegStep2({data, onSubmit, onBack, loading, error}) { function RegStep2({data, onSubmit, onBack, loading, error}) {
const { t } = useTranslation();
return ( return (
<> <>
<div className="confirm-box"> <div className="confirm-box">
<div className="confirm-row"> <div className="confirm-row">
<span className="confirm-label">Имя</span> <span className="confirm-label">{t('auth.nameLabel')}</span>
<span className="confirm-val">{data.name}</span> <span className="confirm-val">{data.name}</span>
</div> </div>
<div className="confirm-divider"/> <div className="confirm-divider"/>
<div className="confirm-row"> <div className="confirm-row">
<span className="confirm-label">Логин</span> <span className="confirm-label">{t('auth.loginLabel')}</span>
<span className="confirm-val accent">@{data.login}</span> <span className="confirm-val accent">@{data.login}</span>
</div> </div>
</div> </div>
@@ -266,30 +270,32 @@ function RegStep2({data, onSubmit, onBack, loading, error}) {
marginBottom: "10px" marginBottom: "10px"
}}>{error}</p>} }}>{error}</p>}
<button className="btn-primary" onClick={onSubmit} disabled={loading}> <button className="btn-primary" onClick={onSubmit} disabled={loading}>
{loading ? "Создаём..." : "Создать аккаунт ✓"} {loading ? t('auth.creating') : t('auth.createAccountBtn')}
</button> </button>
<button className="back-btn" onClick={onBack}>← Назад</button> <button className="back-btn" onClick={onBack}>{t('auth.back')}</button>
</> </>
); );
} }
// ─── success ────────────────────────────────────────────────────────────────── // ─── success ──────────────────────────────────────────────────────────────────
function RegSuccess({name, onLogin}) { function RegSuccess({name, onLogin}) {
const { t } = useTranslation();
return ( return (
<div className="success-wrap"> <div className="success-wrap">
<img src={likeSmile} alt="success" className="success-icon"/> <img src={likeSmile} alt="success" className="success-icon"/>
<p <p
className="success-title">Добро пожаловать, {name}!</p> className="success-title">{t('auth.welcome', { name })}</p>
<p className="success-sub">Аккаунт создан успешно.</p> <p className="success-sub">{t('auth.accountCreated')}</p>
<button className="btn-primary" style={{marginTop: "20px"}} <button className="btn-primary" style={{marginTop: "20px"}}
onClick={onLogin}> onClick={onLogin}>
Войти → {t('auth.loginRedirect')}
</button> </button>
</div> </div>
); );
} }
function RegisterForm({onSwitchToLogin}) { function RegisterForm({onSwitchToLogin}) {
const { t } = useTranslation();
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [form, setForm] = useState( const [form, setForm] = useState(
{name: "", login: "", password: "", password2: ""}); {name: "", login: "", password: "", password2: ""});
@@ -307,7 +313,7 @@ function RegisterForm({onSwitchToLogin}) {
return ( return (
<div className="form-body"> <div className="form-body">
<p className="form-subtitle">Создайте аккаунт</p> <p className="form-subtitle">{t('auth.createAccount')}</p>
<StepDots current={step} total={2} /> <StepDots current={step} total={2} />
{step === 0 && ( {step === 0 && (
@@ -325,8 +331,8 @@ function RegisterForm({onSwitchToLogin}) {
{step === 0 && ( {step === 0 && (
<p className="switch-row"> <p className="switch-row">
Уже есть аккаунт?{" "} {t('auth.hasAccount')}{" "}
<button className="link-btn" onClick={onSwitchToLogin}>Войти</button> <button className="link-btn" onClick={onSwitchToLogin}>{t('auth.loginLink')}</button>
</p> </p>
)} )}
</div> </div>
@@ -337,6 +343,7 @@ function RegisterForm({onSwitchToLogin}) {
// AUTH PAGE — единственная страница, переключает login ↔ register // AUTH PAGE — единственная страница, переключает login ↔ register
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
export default function Authentication() { export default function Authentication() {
const { t } = useTranslation();
const [mode, setMode] = useState("login"); // "login" | "register" const [mode, setMode] = useState("login"); // "login" | "register"
const [animating, setAnimating] = useState(false); const [animating, setAnimating] = useState(false);
@@ -367,7 +374,7 @@ export default function Authentication() {
<div className="logo-wrap"> <div className="logo-wrap">
<MagnifierLogo /> <MagnifierLogo />
</div> </div>
<h1 className="auth-title">Находка</h1> <h1 className="auth-title">{t('app.name')}</h1>
{mode === "login" {mode === "login"
? <LoginForm onSwitchToRegister={() => switchTo("register")} /> ? <LoginForm onSwitchToRegister={() => switchTo("register")} />

View File

@@ -1,6 +1,7 @@
import {useCallback, useEffect, useState} from "react"; import {useCallback, useEffect, useState} from "react";
import CloseIcon from "./icons/CloseIcon"; import CloseIcon from "./icons/CloseIcon";
import { EMPTY_FORM, CATEGORIES } from "../config/feedConstants"; import { EMPTY_FORM, CATEGORY_KEYS } from "../config/feedConstants";
import { useTranslation } from 'react-i18next';
import { usePostMutation } from "../hooks/usePostMutation"; import { usePostMutation } from "../hooks/usePostMutation";
import { useImageUpload, ACCEPT } from "../hooks/useImageUpload"; import { useImageUpload, ACCEPT } from "../hooks/useImageUpload";
import { validatePostForm } from "../utils/validatePostForm"; import { validatePostForm } from "../utils/validatePostForm";
@@ -19,6 +20,7 @@ const MAX_SIZE_MB = 5;
export default function CreateUpdatePost({ onClose, onSuccess, initialData }) { export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
// Режим: редактирование (true) или создание нового (false) // Режим: редактирование (true) или создание нового (false)
const isEdit = !!initialData; const isEdit = !!initialData;
const { t } = useTranslation();
// Поля формы; при редактировании заполняем из initialData // Поля формы; при редактировании заполняем из initialData
const [form, setForm] = useState( const [form, setForm] = useState(
@@ -88,7 +90,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
} }
if (coords && !addressRef.current) { if (coords && !addressRef.current) {
setErrors(prev => ({ ...prev, map: "Подождите, адрес загружается..." })); setErrors(prev => ({ ...prev, map: t('post.mapLoading') }));
return; return;
} }
@@ -136,7 +138,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
{/* Шапка модалки */} {/* Шапка модалки */}
<div className="f-modal-head"> <div className="f-modal-head">
<span className="f-modal-title"> <span className="f-modal-title">
{isEdit ? "Редактировать объявление" : "Новое объявление"} {isEdit ? t('post.editTitle') : t('post.newTitle')}
</span> </span>
<button className="f-close-btn" onClick={onClose}> <button className="f-close-btn" onClick={onClose}>
<CloseIcon/> <CloseIcon/>
@@ -145,31 +147,31 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
{/* Тип: Потеря / Находка */} {/* Тип: Потеря / Находка */}
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Тип объявления</label> <label className="f-m-label">{t('post.typeLabel')}</label>
<div className="f-type-toggle"> <div className="f-type-toggle">
<button <button
className={`f-type-opt${form.type === "loss" ? " loss" : ""}`} className={`f-type-opt${form.type === "loss" ? " loss" : ""}`}
onClick={() => set("type", "loss")} onClick={() => set("type", "loss")}
> >
Потеря {t('post.loss')}
</button> </button>
<button <button
className={`f-type-opt${form.type === "found" ? " found" className={`f-type-opt${form.type === "found" ? " found"
: ""}`} : ""}`}
onClick={() => set("type", "found")} onClick={() => set("type", "found")}
> >
Находка {t('post.found')}
</button> </button>
</div> </div>
</div> </div>
{/* Название */} {/* Название */}
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Название</label> <label className="f-m-label">{t('post.titleLabel')}</label>
<input <input
className={`f-m-field${errors.title ? " err" : ""}`} className={`f-m-field${errors.title ? " err" : ""}`}
type="text" type="text"
placeholder="Например: Кошелёк чёрный кожаный" placeholder={t('post.titlePlaceholder')}
value={form.title} value={form.title}
onChange={e => set("title", e.target.value)} onChange={e => set("title", e.target.value)}
/> />
@@ -178,11 +180,11 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
{/* Описание */} {/* Описание */}
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Описание</label> <label className="f-m-label">{t('post.descriptionLabel')}</label>
<textarea <textarea
className={`f-m-field${errors.description ? " err" : ""}`} className={`f-m-field${errors.description ? " err" : ""}`}
rows={3} rows={3}
placeholder="Подробно опишите предмет — цвет, размер, особые приметы..." placeholder={t('post.descriptionPlaceholder')}
value={form.description} value={form.description}
onChange={e => set("description", e.target.value)} onChange={e => set("description", e.target.value)}
/> />
@@ -192,7 +194,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
{/* Фотографии: сетка превью + кнопка добавления */} {/* Фотографии: сетка превью + кнопка добавления */}
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Фотографии</label> <label className="f-m-label">{t('post.photosLabel')}</label>
{uploadedImages.length > 0 && ( {uploadedImages.length > 0 && (
<div className="f-img-grid"> <div className="f-img-grid">
@@ -203,7 +205,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
className="f-img-remove" className="f-img-remove"
onClick={() => removeImage(img.id)} onClick={() => removeImage(img.id)}
type="button" type="button"
aria-label="Удалить фото" aria-label={t('post.deletePhoto')}
> >
<CloseIcon/> <CloseIcon/>
</button> </button>
@@ -226,27 +228,27 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
disabled={compressing} disabled={compressing}
/> />
{compressing ? ( {compressing ? (
<span>Обработка...</span> <span>{t('post.processing')}</span>
) : ( ) : (
<span>{uploadedImages.length > 0 ? "➕ Добавить ещё" : "📷 Добавить фото"}</span> <span>{uploadedImages.length > 0 ? t('post.addMore') : t('post.addPhoto')}</span>
)} )}
</label> </label>
{imgError && <p className="f-err-msg">{imgError}</p>} {imgError && <p className="f-err-msg">{imgError}</p>}
<p className="f-hint">JPEG / PNG / WebP · до {MAX_SIZE_MB} МБ · можно несколько</p> <p className="f-hint">{t('post.photoHint', { maxSize: MAX_SIZE_MB })}</p>
</div> </div>
{/* Категория */} {/* Категория */}
<div className="f-m-row"> <div className="f-m-row">
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Категория</label> <label className="f-m-label">{t('post.categoryLabel')}</label>
<select <select
className={`f-m-field${errors.category ? " err" : ""}`} className={`f-m-field${errors.category ? " err" : ""}`}
value={form.category} value={form.category}
onChange={e => set("category", e.target.value)} onChange={e => set("category", e.target.value)}
> >
<option value="">Выбрать...</option> <option value="">{t('categories.select')}</option>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)} {CATEGORY_KEYS.map(k => <option key={k} value={t(k)}>{t(k)}</option>)}
</select> </select>
{errors.category && <p {errors.category && <p
className="f-err-msg">{errors.category}</p>} className="f-err-msg">{errors.category}</p>}
@@ -255,7 +257,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
{/* Карта */} {/* Карта */}
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Место на карте</label> <label className="f-m-label">{t('post.mapLabel')}</label>
<div style={{ <div style={{
width: "100%", width: "100%",
height: 260, height: 260,
@@ -265,17 +267,16 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
<div ref={mapRef} style={{width: "100%", height: "100%"}}/> <div ref={mapRef} style={{width: "100%", height: "100%"}}/>
</div> </div>
{!coords && <p className="f-hint">Нажмите на карту, чтобы отметить {!coords && <p className="f-hint">{t('post.mapHint')}</p>}
место</p>}
{errors.map && <p className="f-err-msg">{errors.map}</p>} {errors.map && <p className="f-err-msg">{errors.map}</p>}
</div> </div>
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Адрес</label> <label className="f-m-label">{t('post.addressLabel')}</label>
<input <input
className={`f-m-field${errors.address ? " err" : ""}`} className={`f-m-field${errors.address ? " err" : ""}`}
type="text" type="text"
placeholder="Например: Москва, ул. Ленина, 5" placeholder={t('post.addressPlaceholder')}
value={form.address} value={form.address}
onChange={e => set("address", e.target.value)} onChange={e => set("address", e.target.value)}
/> />
@@ -284,11 +285,11 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
{/* Телефон с автоматической маской */} {/* Телефон с автоматической маской */}
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Номер телефона</label> <label className="f-m-label">{t('post.phoneLabel')}</label>
<input <input
className={`f-m-field${errors.phone ? " err" : ""}`} className={`f-m-field${errors.phone ? " err" : ""}`}
type="tel" type="tel"
placeholder="+7 (___) ___-__-__" placeholder={t('post.phonePlaceholder')}
value={form.phone} value={form.phone}
onChange={e => set("phone", formatPhone(e.target.value))} onChange={e => set("phone", formatPhone(e.target.value))}
/> />
@@ -302,7 +303,7 @@ export default function CreateUpdatePost({ onClose, onSuccess, initialData }) {
disabled={loading}> disabled={loading}>
<div className="f-m-submit-row"> <div className="f-m-submit-row">
{loading && <div className="f-m-spinner"/>} {loading && <div className="f-m-spinner"/>}
{isEdit ? "Сохранить изменения" : "Разместить объявление"} {isEdit ? t('post.saveChanges') : t('post.publish')}
</div> </div>
</button> </button>

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useTranslation } from 'react-i18next';
const MONTHS = ["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"];
const DAYS = ["Пн","Вт","Ср","Чт","Пт","Сб","Вс"];
export default function DateCalendarPopup({ date, onClose }) { export default function DateCalendarPopup({ date, onClose }) {
const { t } = useTranslation();
const months = t('calendar.months', { returnObjects: true });
const days = t('calendar.days', { returnObjects: true });
const ref = useRef(null); const ref = useRef(null);
const d = new Date(date); const d = new Date(date);
const [year, setYear] = useState(d.getFullYear()); const [year, setYear] = useState(d.getFullYear());
@@ -30,11 +31,11 @@ export default function DateCalendarPopup({ date, onClose }) {
<div className="f-date-calendar" ref={ref}> <div className="f-date-calendar" ref={ref}>
<div className="f-cal-header"> <div className="f-cal-header">
<button onClick={() => setMonth(m => m === 0 ? (setYear(y => y - 1), 11) : m - 1)}>‹</button> <button onClick={() => setMonth(m => m === 0 ? (setYear(y => y - 1), 11) : m - 1)}>‹</button>
<span>{MONTHS[month]} {year}</span> <span>{months[month]} {year}</span>
<button onClick={() => setMonth(m => m === 11 ? (setYear(y => y + 1), 0) : m + 1)}>›</button> <button onClick={() => setMonth(m => m === 11 ? (setYear(y => y + 1), 0) : m + 1)}>›</button>
</div> </div>
<div className="f-cal-grid"> <div className="f-cal-grid">
{DAYS.map(d => <div key={d} className="f-cal-day-name">{d}</div>)} {days.map(d => <div key={d} className="f-cal-day-name">{d}</div>)}
{cells.map((day, i) => {cells.map((day, i) =>
day ? ( day ? (
<div key={i} <div key={i}

View File

@@ -10,8 +10,10 @@ import { useChats } from "../hooks/useChats";
import { useAuthContext } from "../context/AuthContext"; import { useAuthContext } from "../context/AuthContext";
import { API_URL } from "../config/authConstants"; import { API_URL } from "../config/authConstants";
import DateCalendarPopup from "./DateCalendarPopup"; import DateCalendarPopup from "./DateCalendarPopup";
import { useTranslation } from 'react-i18next';
export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMarkFound, isOwner, onImageClick, onAuthRequired }) { export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMarkFound, isOwner, onImageClick, onAuthRequired }) {
const { t } = useTranslation();
const [showCalendar, setShowCalendar] = useState(false); const [showCalendar, setShowCalendar] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { createOrGetChat } = useChats(); const { createOrGetChat } = useChats();
@@ -49,17 +51,17 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
: ad.userRole === 'METRO' ? 'f-card-role-metro' : ''; : ad.userRole === 'METRO' ? 'f-card-role-metro' : '';
const roleBadge = ad.userRole === 'LOST_AND_FOUND' ? ( const roleBadge = ad.userRole === 'LOST_AND_FOUND' ? (
<div className="f-role-badge f-role-lost-found" title="Бюро находок"> <div className="f-role-badge f-role-lost-found" title={t('postCard.lostFoundBureau')}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/> <path d="M12 6v6l4 2"/>
</svg> </svg>
Бюро находок {t('postCard.lostFoundBureau')}
</div> </div>
) : ad.userRole === 'METRO' ? ( ) : ad.userRole === 'METRO' ? (
<div className="f-role-badge f-role-metro" title="Метрополитен"> <div className="f-role-badge f-role-metro" title={t('postCard.metro')}>
<span className="f-role-metro-letter">М</span> <span className="f-role-metro-letter">М</span>
Метрополитен {t('postCard.metro')}
</div> </div>
) : null; ) : null;
@@ -76,13 +78,13 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
<div className="f-card-badges"> <div className="f-card-badges">
{ad.category && <div className="f-cat-badge">{ad.category}</div>} {ad.category && <div className="f-cat-badge">{ad.category}</div>}
<div className={`f-type-badge ${ad.type === "loss" ? "f-type-loss" : "f-type-found"}`}> <div className={`f-type-badge ${ad.type === "loss" ? "f-type-loss" : "f-type-found"}`}>
{ad.type === "loss" ? "Потеря" : "Находка"} {ad.type === "loss" ? t('postCard.loss') : t('postCard.found')}
</div> </div>
{roleBadge} {roleBadge}
</div> </div>
<div className="f-card-title"> <div className="f-card-title">
{ad.title} {ad.title}
{isClosed && <span className="f-status-badge f-status-closed">✓ Найдено</span>} {isClosed && <span className="f-status-badge f-status-closed">{t('postCard.foundStatus')}</span>}
</div> </div>
<div className="f-card-desc">{ad.description}</div> <div className="f-card-desc">{ad.description}</div>
<div className="f-card-meta"> <div className="f-card-meta">
@@ -97,7 +99,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
</div> </div>
{address && ( {address && (
<button className="f-card-map" onClick={e => e.stopPropagation()}> <button className="f-card-map" onClick={e => e.stopPropagation()}>
<MapIcon /> Показать на карте — {address} <MapIcon /> {t('postCard.showOnMap', { address })}
</button> </button>
)} )}
<div className="f-card-foot"> <div className="f-card-foot">
@@ -112,7 +114,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
<div className="f-card-foot-right"> <div className="f-card-foot-right">
{!isOwner && !isClosed && ( {!isOwner && !isClosed && (
<button className="f-card-contact" onClick={handleContact}> <button className="f-card-contact" onClick={handleContact}>
Написать {t('postCard.write')}
</button> </button>
)} )}
{onEdit && isOwner && !isClosed && ( {onEdit && isOwner && !isClosed && (
@@ -130,7 +132,7 @@ export default function PostOnFeed({ ad, liked, onLike, onEdit, onDelete, onMark
{onMarkFound && isOwner && !isClosed && ( {onMarkFound && isOwner && !isClosed && (
<button className="f-card-found-btn" <button className="f-card-found-btn"
onClick={e => { e.stopPropagation(); onMarkFound(ad.id); }}> onClick={e => { e.stopPropagation(); onMarkFound(ad.id); }}>
✓ Вещь найдена {t('postCard.itemFound')}
</button> </button>
)} )}
<button className={`f-like-btn${liked ? " liked" : ""}`} <button className={`f-like-btn${liked ? " liked" : ""}`}

View File

@@ -1,13 +1,14 @@
import { useTranslation } from 'react-i18next';
export default function RightSidebar() { export default function RightSidebar() {
const { t } = useTranslation();
return ( return (
<div className="f-right"> <div className="f-right">
<div className="f-right-label">Реклама</div> <div className="f-right-label">{t('rightSidebar.advertisement')}</div>
<div className="f-right-empty"> <div className="f-right-empty">
<div className="f-right-empty-icon">📢</div> <div className="f-right-empty-icon">📢</div>
<div className="f-right-empty-text"> <div className="f-right-empty-text" dangerouslySetInnerHTML={{ __html: t('rightSidebar.emptyText') }} />
Здесь будет<br />ваша реклама <button className="f-add-promo-btn">{t('rightSidebar.addAd')}</button>
</div>
<button className="f-add-promo-btn">+ Добавить рекламу</button>
</div> </div>
</div> </div>
); );

View File

@@ -1,10 +1,11 @@
import { useTranslation } from 'react-i18next';
import CloseIcon from "./icons/CloseIcon"; import CloseIcon from "./icons/CloseIcon";
import "../styles/rules.css"; import "../styles/rules.css";
const RULES = [ const RULES = [
{ {
num: "1", num: "1",
title: "Общие положения", titleKey: "rules.section1Title",
content: ( content: (
<p> <p>
Платформа «Находка» предназначена исключительно для помощи людям в поиске утерянных вещей Платформа «Находка» предназначена исключительно для помощи людям в поиске утерянных вещей
@@ -15,7 +16,7 @@ const RULES = [
}, },
{ {
num: "2", num: "2",
title: "Что можно размещать", titleKey: "rules.section2Title",
content: ( content: (
<ul> <ul>
<li>Объявления о потере личных вещей, документов, животных, транспортных средств</li> <li>Объявления о потере личных вещей, документов, животных, транспортных средств</li>
@@ -27,7 +28,7 @@ const RULES = [
}, },
{ {
num: "3", num: "3",
title: "Что запрещено размещать", titleKey: "rules.section3Title",
content: ( content: (
<> <>
<p className="rules-sub-title">3.1. Запрещённый контент</p> <p className="rules-sub-title">3.1. Запрещённый контент</p>
@@ -55,7 +56,7 @@ const RULES = [
}, },
{ {
num: "4", num: "4",
title: "Требования к объявлению", titleKey: "rules.section4Title",
content: ( content: (
<ul> <ul>
<li>Описание должно быть достоверным и точным</li> <li>Описание должно быть достоверным и точным</li>
@@ -68,7 +69,7 @@ const RULES = [
}, },
{ {
num: "5", num: "5",
title: "Вознаграждение", titleKey: "rules.section5Title",
content: ( content: (
<> <>
<p>Предложение вознаграждения нашедшему является добровольным и законным правом владельца. При этом:</p> <p>Предложение вознаграждения нашедшему является добровольным и законным правом владельца. При этом:</p>
@@ -82,7 +83,7 @@ const RULES = [
}, },
{ {
num: "6", num: "6",
title: "Найденные документы", titleKey: "rules.section6Title",
content: ( content: (
<> <>
<p>Нахождение чужих документов обязывает вас согласно законодательству РФ:</p> <p>Нахождение чужих документов обязывает вас согласно законодательству РФ:</p>
@@ -97,7 +98,7 @@ const RULES = [
}, },
{ {
num: "7", num: "7",
title: "Животные", titleKey: "rules.section7Title",
content: ( content: (
<ul> <ul>
<li>Найденное животное необходимо по возможности обеспечить едой и кровом до возврата владельцу</li> <li>Найденное животное необходимо по возможности обеспечить едой и кровом до возврата владельцу</li>
@@ -108,7 +109,7 @@ const RULES = [
}, },
{ {
num: "8", num: "8",
title: "Конфиденциальность", titleKey: "rules.section8Title",
content: ( content: (
<ul> <ul>
<li>Не публикуйте в объявлении избыточные персональные данные — достаточно способа связи</li> <li>Не публикуйте в объявлении избыточные персональные данные — достаточно способа связи</li>
@@ -119,7 +120,7 @@ const RULES = [
}, },
{ {
num: "9", num: "9",
title: "Модерация", titleKey: "rules.section9Title",
content: ( content: (
<> <>
<p>Администрация платформы вправе:</p> <p>Администрация платформы вправе:</p>
@@ -133,7 +134,7 @@ const RULES = [
}, },
{ {
num: "10", num: "10",
title: "Ответственность пользователей", titleKey: "rules.section10Title",
content: ( content: (
<p> <p>
Пользователь самостоятельно несёт ответственность за содержание размещаемых объявлений. Пользователь самостоятельно несёт ответственность за содержание размещаемых объявлений.
@@ -145,6 +146,7 @@ const RULES = [
]; ];
export default function RulesModal({ onClose }) { export default function RulesModal({ onClose }) {
const { t } = useTranslation();
return ( return (
<div className="f-modal-bg" onClick={e => e.target === e.currentTarget && onClose()}> <div className="f-modal-bg" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="f-modal rules-modal"> <div className="f-modal rules-modal">
@@ -152,7 +154,7 @@ export default function RulesModal({ onClose }) {
<div className="f-modal-head"> <div className="f-modal-head">
<div className="rules-modal-title-wrap"> <div className="rules-modal-title-wrap">
<span className="rules-modal-icon">📋</span> <span className="rules-modal-icon">📋</span>
<span className="f-modal-title">Правила площадки «Находка»</span> <span className="f-modal-title">{t('rules.title')}</span>
</div> </div>
<button className="f-close-btn" onClick={onClose}> <button className="f-close-btn" onClick={onClose}>
<CloseIcon /> <CloseIcon />
@@ -165,7 +167,7 @@ export default function RulesModal({ onClose }) {
<div key={rule.num} className="rules-section"> <div key={rule.num} className="rules-section">
<div className="rules-section-head"> <div className="rules-section-head">
<span className="rules-num">{rule.num}</span> <span className="rules-num">{rule.num}</span>
<span className="rules-title">{rule.title}</span> <span className="rules-title">{t(rule.titleKey)}</span>
</div> </div>
<div className="rules-body">{rule.content}</div> <div className="rules-body">{rule.content}</div>
</div> </div>

View File

@@ -4,6 +4,7 @@ import {NavIcons} from "./icons/NavIcons";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTheme } from "../context/ThemeContext"; import { useTheme } from "../context/ThemeContext";
import { useUnread } from "../context/UnreadContext"; import { useUnread } from "../context/UnreadContext";
import { useTranslation } from 'react-i18next';
export default function Sidebar({ export default function Sidebar({
open, open,
@@ -18,40 +19,48 @@ export default function Sidebar({
const navigate = useNavigate(); const navigate = useNavigate();
const { theme, toggle: toggleTheme } = useTheme(); const { theme, toggle: toggleTheme } = useTheme();
const { totalUnread } = useUnread(); const { totalUnread } = useUnread();
const { t, i18n } = useTranslation();
const toggleLang = () => {
const next = i18n.language === 'ru' ? 'en' : 'ru';
i18n.changeLanguage(next);
localStorage.setItem('i18nextLng', next);
};
return ( return (
<> <>
<div className={`f-overlay${open ? " show" : ""}`} onClick={onClose} /> <div className={`f-overlay${open ? " show" : ""}`} onClick={onClose} />
<div className={`f-sidebar${open ? " open" : ""}`}> <div className={`f-sidebar${open ? " open" : ""}`}>
<div className="f-sb-head"> <div className="f-sb-head">
<div className="f-sb-logo"><LogoIcon /></div> <div className="f-sb-logo"><LogoIcon /></div>
<span className="f-sb-name">Находка</span> <span className="f-sb-name">{t('app.name')}</span>
</div> </div>
<nav className="f-sb-nav"> <nav className="f-sb-nav">
{NAV_ITEMS.map(({ icon, label }) => { {NAV_ITEMS.map(({ icon, key: itemKey, labelKey }) => {
const Ic = NavIcons[icon]; const Ic = NavIcons[icon];
const handleClick = () => { const handleClick = () => {
// «Мои объявления» требует авторизации // «Мои объявления» требует авторизации
if (!isAuthenticated && label === "Мои объявления") { if (!isAuthenticated && itemKey === "myads") {
onLoginClick(); onLoginClick();
} else if (label === "Чат") { } else if (itemKey === "chat") {
if (!isAuthenticated) { if (!isAuthenticated) {
onLoginClick(); onLoginClick();
} else { } else {
navigate("/chats"); navigate("/chats");
} }
} else { } else {
onNavClick(label); onNavClick(itemKey);
} }
onClose(); onClose();
}; };
return ( return (
<div <div
key={label} key={itemKey}
className={`f-sb-item${activeNav === label ? " active" : ""}`} className={`f-sb-item${activeNav === itemKey ? " active" : ""}`}
onClick={handleClick} onClick={handleClick}
> >
<Ic /> {label} <Ic /> {t(labelKey)}
{label === "Чат" && totalUnread > 0 && ( {itemKey === "chat" && totalUnread > 0 && (
<span className="f-sb-unread-badge">{totalUnread > 99 ? "99+" : totalUnread}</span> <span className="f-sb-unread-badge">{totalUnread > 99 ? "99+" : totalUnread}</span>
)} )}
</div> </div>
@@ -59,29 +68,29 @@ export default function Sidebar({
})} })}
</nav> </nav>
<div className="f-sb-bottom"> <div className="f-sb-bottom">
{NAV_BOTTOM.map(({ icon, label }) => { {NAV_BOTTOM.map(({ icon, key: itemKey, labelKey }) => {
const Ic = NavIcons[icon]; const Ic = NavIcons[icon];
const handleBottomClick = () => { const handleBottomClick = () => {
if (label === "Правила площадки") { if (itemKey === "rules") {
onRulesClick?.(); onRulesClick?.();
onClose(); onClose();
return; return;
} }
if (label === "Отзывы") { if (itemKey === "reviews") {
onReviewsClick?.(); onReviewsClick?.();
onClose(); onClose();
return; return;
} }
onNavClick(label); onNavClick(itemKey);
onClose(); onClose();
}; };
return ( return (
<div <div
key={label} key={itemKey}
className={`f-sb-item${activeNav === label ? " active" : ""}`} className={`f-sb-item${activeNav === itemKey ? " active" : ""}`}
onClick={handleBottomClick} onClick={handleBottomClick}
> >
<Ic /> {label} <Ic /> {t(labelKey)}
</div> </div>
); );
})} })}
@@ -97,7 +106,14 @@ export default function Sidebar({
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/> <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg> </svg>
)} )}
{theme === "dark" ? "Светлая тема" : "Тёмная тема"} {theme === "dark" ? t('sidebar.lightTheme') : t('sidebar.darkTheme')}
</div>
<div className="f-sb-item f-sb-lang-toggle" onClick={toggleLang}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
{i18n.language === 'ru' ? 'English' : 'Русский'}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,9 +1,12 @@
import { useTranslation } from 'react-i18next';
export default function FinalCta({ onClick }) { export default function FinalCta({ onClick }) {
const { t } = useTranslation();
return ( return (
<section className="nk-final-cta"> <section className="nk-final-cta">
<h2 className="nk-h2">Кто-то прямо сейчас ищет то, что вы нашли</h2> <h2 className="nk-h2">{t('landing.finalCta')}</h2>
<button className="nk-cta-primary" onClick={onClick}> <button className="nk-cta-primary" onClick={onClick}>
Открыть Находку {t('landing.openNakhodka')}
</button> </button>
</section> </section>
); );

View File

@@ -1,7 +1,10 @@
import { useTranslation } from 'react-i18next';
export default function Footer() { export default function Footer() {
const { t } = useTranslation();
return ( return (
<footer className="nk-footer"> <footer className="nk-footer">
© {new Date().getFullYear()} Находка. Возвращаем найденное. {t('landing.footer', { year: new Date().getFullYear() })}
</footer> </footer>
); );
} }

View File

@@ -1,8 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import Navbar from "./Navbar"; import Navbar from "./Navbar";
import RadarBeacon from "./RadarBeacon"; import RadarBeacon from "./RadarBeacon";
import { useTranslation } from 'react-i18next';
export default function Hero({ onCtaClick, onLoginClick }) { export default function Hero({ onCtaClick, onLoginClick }) {
const { t } = useTranslation();
const [hover, setHover] = useState(false); const [hover, setHover] = useState(false);
return ( return (
@@ -14,15 +16,14 @@ export default function Hero({ onCtaClick, onLoginClick }) {
<Navbar onLoginClick={onLoginClick || onCtaClick} /> <Navbar onLoginClick={onLoginClick || onCtaClick} />
<div className="nk-hero-text"> <div className="nk-hero-text">
<span className="nk-eyebrow">Сервис поиска пропавших питомцев и вещей</span> <span className="nk-eyebrow">{t('landing.eyebrow')}</span>
<h1 className="nk-h1"> <h1 className="nk-h1">
Потерял — не значит {t('landing.heroTitle')}
<br /> <br />
<span className="nk-accent">потерял навсегда</span> <span className="nk-accent">{t('landing.heroAccent')}</span>
</h1> </h1>
<p className="nk-lead"> <p className="nk-lead">
Разместите объявление о пропаже или находке — Находка сама сопоставит {t('landing.heroLead')}
похожие записи по фото, месту и описанию и покажет совпадения рядом с вами.
</p> </p>
<div className="nk-cta-row"> <div className="nk-cta-row">
<button <button
@@ -31,7 +32,7 @@ export default function Hero({ onCtaClick, onLoginClick }) {
onMouseLeave={() => setHover(false)} onMouseLeave={() => setHover(false)}
onClick={onCtaClick} onClick={onCtaClick}
> >
Начать поиск {t('landing.startSearch')}
<span <span
style={{ style={{
marginLeft: 8, marginLeft: 8,

View File

@@ -1,10 +1,29 @@
import { useTranslation } from 'react-i18next';
export default function Navbar({ onLoginClick }) { export default function Navbar({ onLoginClick }) {
const { t, i18n } = useTranslation();
const toggleLang = () => {
const next = i18n.language === 'ru' ? 'en' : 'ru';
i18n.changeLanguage(next);
localStorage.setItem('i18nextLng', next);
};
return ( return (
<nav className="nk-nav"> <nav className="nk-nav">
<span className="nk-logo">Находка</span> <span className="nk-logo">{t('app.name')}</span>
<button className="nk-nav-btn" onClick={onLoginClick}> <div className="nk-nav-right">
Войти <button className="nk-lang-btn" onClick={toggleLang}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
{i18n.language === 'ru' ? 'EN' : 'RU'}
</button> </button>
<button className="nk-nav-btn" onClick={onLoginClick}>
{t('landing.login')}
</button>
</div>
</nav> </nav>
); );
} }

View File

@@ -1,15 +1,22 @@
import { steps } from "../../config/landingConstants"; import { useTranslation } from 'react-i18next';
const STEP_KEYS = [
{ titleKey: "landing.step1Title", textKey: "landing.step1Text" },
{ titleKey: "landing.step2Title", textKey: "landing.step2Text" },
{ titleKey: "landing.step3Title", textKey: "landing.step3Text" },
];
export default function StepsSection() { export default function StepsSection() {
const { t } = useTranslation();
return ( return (
<section className="nk-steps-section"> <section className="nk-steps-section">
<h2 className="nk-h2">Как это работает</h2> <h2 className="nk-h2">{t('landing.howItWorks')}</h2>
<div className="nk-steps-grid"> <div className="nk-steps-grid">
{steps.map((s) => ( {STEP_KEYS.map((s, i) => (
<div className="nk-step-card" key={s.n}> <div className="nk-step-card" key={i}>
<span className="nk-step-num">{s.n}</span> <span className="nk-step-num">{`0${i + 1}`}</span>
<h3 className="nk-step-title">{s.title}</h3> <h3 className="nk-step-title">{t(s.titleKey)}</h3>
<p className="nk-step-text">{s.text}</p> <p className="nk-step-text">{t(s.textKey)}</p>
</div> </div>
))} ))}
</div> </div>

View File

@@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { useTranslation } from 'react-i18next';
import CloseIcon from "../icons/CloseIcon"; import CloseIcon from "../icons/CloseIcon";
// ─── Input field component ─────────────────────────────────────────────────── // ─── Input field component ───────────────────────────────────────────────────
@@ -24,11 +25,12 @@ function ImageUpload({
onFileChange, onFileChange,
onRemove, onRemove,
}) { }) {
const { t } = useTranslation();
const MAX_SIZE_MB = 5; const MAX_SIZE_MB = 5;
return ( return (
<div className="f-m-group"> <div className="f-m-group">
<label className="f-m-label">Фото профиля</label> <label className="f-m-label">{t('profile.photoLabel')}</label>
{displayImage ? ( {displayImage ? (
<div className="f-img-preview-wrap"> <div className="f-img-preview-wrap">
<img src={displayImage} alt="profile" className="f-img-preview" /> <img src={displayImage} alt="profile" className="f-img-preview" />
@@ -36,7 +38,7 @@ function ImageUpload({
className="f-img-remove" className="f-img-remove"
onClick={onRemove} onClick={onRemove}
type="button" type="button"
aria-label="Удалить фото" aria-label={t('profile.deletePhoto')}
> >
<CloseIcon /> <CloseIcon />
</button> </button>
@@ -51,20 +53,21 @@ function ImageUpload({
className="f-img-input" className="f-img-input"
disabled={compressing} disabled={compressing}
/> />
{compressing ? <span>Обработка...</span> : <span>📷 Добавить фото</span>} {compressing ? <span>{t('profile.processing')}</span> : <span>{t('profile.addPhoto')}</span>}
</label> </label>
)} )}
{imgError && <p className="f-err-msg">{imgError}</p>} {imgError && <p className="f-err-msg">{imgError}</p>}
<p className="f-hint">JPEG / PNG / WebP · до {MAX_SIZE_MB} МБ</p> <p className="f-hint">{t('profile.photoHint', { maxSize: MAX_SIZE_MB })}</p>
</div> </div>
); );
} }
// ─── Login field ───────────────────────────────────────────────────────────── // ─── Login field ─────────────────────────────────────────────────────────────
function LoginField({ form, errors, onChange }) { function LoginField({ form, errors, onChange }) {
const { t } = useTranslation();
return ( return (
<InputField <InputField
label="Логин" label={t('profile.loginLabel')}
placeholder="username" placeholder="username"
value={form.login} value={form.login}
error={errors.login} error={errors.login}
@@ -75,18 +78,19 @@ function LoginField({ form, errors, onChange }) {
// ─── Name fields row ───────────────────────────────────────────────────────── // ─── Name fields row ─────────────────────────────────────────────────────────
function NameFieldsRow({ form, errors, onChange }) { function NameFieldsRow({ form, errors, onChange }) {
const { t } = useTranslation();
return ( return (
<div className="f-m-row"> <div className="f-m-row">
<InputField <InputField
label="Имя" label={t('profile.nameLabel')}
placeholder="Иван" placeholder={t('profile.namePlaceholder')}
value={form.firstName} value={form.firstName}
error={errors.firstName} error={errors.firstName}
onChange={(e) => onChange("firstName", e.target.value)} onChange={(e) => onChange("firstName", e.target.value)}
/> />
<InputField <InputField
label="Фамилия" label={t('profile.surnameLabel')}
placeholder="Иванов" placeholder={t('profile.surnamePlaceholder')}
value={form.lastName} value={form.lastName}
error={errors.lastName} error={errors.lastName}
onChange={(e) => onChange("lastName", e.target.value)} onChange={(e) => onChange("lastName", e.target.value)}
@@ -97,10 +101,11 @@ function NameFieldsRow({ form, errors, onChange }) {
// ─── Contact fields ───────────────────────────────────────────────────────── // ─── Contact fields ─────────────────────────────────────────────────────────
function ContactFields({ form, errors, onChange, onPhoneChange }) { function ContactFields({ form, errors, onChange, onPhoneChange }) {
const { t } = useTranslation();
return ( return (
<> <>
<InputField <InputField
label="Номер телефона" label={t('profile.phoneLabel')}
type="tel" type="tel"
placeholder="+7 (___) ___-__-__" placeholder="+7 (___) ___-__-__"
value={form.phone} value={form.phone}
@@ -108,7 +113,7 @@ function ContactFields({ form, errors, onChange, onPhoneChange }) {
onChange={(e) => onPhoneChange(e.target.value)} onChange={(e) => onPhoneChange(e.target.value)}
/> />
<InputField <InputField
label="Электронная почта" label={t('profile.emailLabel')}
type="email" type="email"
placeholder="example@email.com" placeholder="example@email.com"
value={form.email} value={form.email}
@@ -121,6 +126,7 @@ function ContactFields({ form, errors, onChange, onPhoneChange }) {
// ─── Submit button ────────────────────────────────────────────────────────── // ─── Submit button ──────────────────────────────────────────────────────────
function SubmitButton({ loading, onSubmit }) { function SubmitButton({ loading, onSubmit }) {
const { t } = useTranslation();
return ( return (
<button <button
className="f-m-submit" className="f-m-submit"
@@ -129,7 +135,7 @@ function SubmitButton({ loading, onSubmit }) {
> >
<div className="f-m-submit-row"> <div className="f-m-submit-row">
{loading && <div className="f-m-spinner" />} {loading && <div className="f-m-spinner" />}
Сохранить профиль {t('profile.save')}
</div> </div>
</button> </button>
); );
@@ -137,9 +143,10 @@ function SubmitButton({ loading, onSubmit }) {
// ─── Modal header ────────────────────────────────────────────────────────── // ─── Modal header ──────────────────────────────────────────────────────────
function ModalHeader({ onClose }) { function ModalHeader({ onClose }) {
const { t } = useTranslation();
return ( return (
<div className="f-modal-head"> <div className="f-modal-head">
<span className="f-modal-title">Редактирование профиля</span> <span className="f-modal-title">{t('profile.editTitle')}</span>
<button className="f-close-btn" onClick={onClose}> <button className="f-close-btn" onClick={onClose}>
<CloseIcon /> <CloseIcon />
</button> </button>

View File

@@ -1,6 +1,9 @@
import { useTranslation } from 'react-i18next';
// ─── Profile info component ───────────────────────────────────────────────── // ─── Profile info component ─────────────────────────────────────────────────
export default function ProfileInfo({ firstName, email }) { export default function ProfileInfo({ firstName, email }) {
const displayName = firstName || "Профиль"; const { t } = useTranslation();
const displayName = firstName || t('profile.default');
return ( return (
<div className="f-profile-info"> <div className="f-profile-info">

View File

@@ -1,4 +1,5 @@
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { useTranslation } from 'react-i18next';
import { formatPhone } from "../../utils/formatPhone"; import { formatPhone } from "../../utils/formatPhone";
import { useImageUpload } from "../../hooks/useImageUpload"; import { useImageUpload } from "../../hooks/useImageUpload";
import { useUpdateProfile } from "../../hooks/useUpdateProfile"; import { useUpdateProfile } from "../../hooks/useUpdateProfile";
@@ -14,6 +15,7 @@ import {
} from "./ProfileFormFields"; } from "./ProfileFormFields";
export default function ProfileModal({ onClose, initialData = {} }) { export default function ProfileModal({ onClose, initialData = {} }) {
const { t } = useTranslation();
const { user, updateUser, logout } = useAuthContext(); const { user, updateUser, logout } = useAuthContext();
const { updateProfile, loading: saving, error: saveError } = useUpdateProfile(); const { updateProfile, loading: saving, error: saveError } = useUpdateProfile();
const [form, setForm] = useState(initializeFormState(initialData)); const [form, setForm] = useState(initializeFormState(initialData));
@@ -111,7 +113,7 @@ export default function ProfileModal({ onClose, initialData = {} }) {
className="f-modal-logout" className="f-modal-logout"
onClick={() => { logout(); onClose(); }} onClick={() => { logout(); onClose(); }}
> >
Выйти {t('profile.logout')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,21 +1,23 @@
import i18n from '../../i18n';
// ─── Form validation logic ────────────────────────────────────────────────── // ─── Form validation logic ──────────────────────────────────────────────────
export function validateProfileForm(form) { export function validateProfileForm(form) {
const errors = {}; const errors = {};
if (!form.firstName.trim()) { if (!form.firstName.trim()) {
errors.firstName = "Введите имя"; errors.firstName = i18n.t('profile.validateNameRequired');
} }
if (!form.lastName.trim()) { if (!form.lastName.trim()) {
errors.lastName = "Введите фамилию"; errors.lastName = i18n.t('profile.validateSurnameRequired');
} }
if (form.email && !form.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) { if (form.email && !form.email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
errors.email = "Некорректный email"; errors.email = i18n.t('profile.validateEmailInvalid');
} }
if (form.phone && form.phone.replace(/\D/g, "").length < 11) { if (form.phone && form.phone.replace(/\D/g, "").length < 11) {
errors.phone = "Некорректный номер телефона"; errors.phone = i18n.t('profile.validatePhoneInvalid');
} }
return errors; return errors;

View File

@@ -1,22 +1,20 @@
// ─── CONSTANTS ────────────────────────────────────────────────────────────────
export const NAV_ITEMS = [ export const NAV_ITEMS = [
{ icon: "grid", label: "Главная" }, { icon: "grid", key: "home", labelKey: "nav.home" },
{ icon: "file", label: "Мои объявления" }, { icon: "file", key: "myads", labelKey: "nav.myAds" },
{ icon: "box", label: "Вещи" }, { icon: "box", key: "items", labelKey: "nav.items" },
{ icon: "paw", label: "Питомцы" }, { icon: "paw", key: "pets", labelKey: "nav.pets" },
{ icon: "chat", label: "Чат" }, { icon: "chat", key: "chat", labelKey: "nav.chat" },
]; ];
export const NAV_BOTTOM = [ export const NAV_BOTTOM = [
{ icon: "star", label: "Отзывы" }, { icon: "star", key: "reviews", labelKey: "nav.reviews" },
{ icon: "rules", label: "Правила площадки" }, { icon: "rules", key: "rules", labelKey: "nav.rules" },
]; ];
export const CATEGORIES = [ export const CATEGORY_KEYS = [
"Вещь", "Питомец", "Другое", "categories.item", "categories.pet", "categories.other",
]; ];
// ─── EMPTY FORM ───────────────────────────────────────────────────────────────
export const EMPTY_FORM = { export const EMPTY_FORM = {
type: "loss", type: "loss",
title: "", title: "",

View File

@@ -10,7 +10,7 @@ export function useAuth() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const login = async (login, password) => { const login = async (login, password) => {
if (!login || !password) { setError("Введите логин и пароль"); return; } if (!login || !password) { setError("Enter login and password"); return; }
setLoading(true); setError(""); setLoading(true); setError("");
try { try {
@@ -22,7 +22,7 @@ export function useAuth() {
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
throw new Error(data.message || "Неверный логин или пароль"); throw new Error(data.message || "Invalid login or password");
} }
const { token, user } = await res.json(); const { token, user } = await res.json();

View File

@@ -25,7 +25,7 @@ export function useCreatePost() {
const data = await res.json(); // ← объявляем ПОСЛЕ await res const data = await res.json(); // ← объявляем ПОСЛЕ await res
if (!res.ok) { if (!res.ok) {
throw new Error(data.message || "Ошибка при создании объявления"); throw new Error(data.message || "Error creating ad");
} }
setSuccess(true); setSuccess(true);

View File

@@ -21,7 +21,7 @@ export function useDeleteChat() {
} }
return true; return true;
} catch (err) { } catch (err) {
setError(err.message || "Не удалось удалить чат"); setError(err.message || "Failed to delete chat");
return false; return false;
} finally { } finally {
setLoading(false); setLoading(false);

View File

@@ -36,11 +36,11 @@ export function useImageUpload(existingUrls = []) {
for (const file of files) { for (const file of files) {
if (!file.type.startsWith("image/")) { if (!file.type.startsWith("image/")) {
setImgError("Только изображения (JPEG, PNG, WebP)"); setImgError("Images only (JPEG, PNG, WebP)");
return; return;
} }
if (file.size > MAX_SIZE_MB * 1024 * 1024) { if (file.size > MAX_SIZE_MB * 1024 * 1024) {
setImgError(`Файл слишком большой — максимум ${MAX_SIZE_MB} МБ`); setImgError(`File too large — max ${MAX_SIZE_MB} MB`);
return; return;
} }
} }
@@ -57,7 +57,7 @@ export function useImageUpload(existingUrls = []) {
})); }));
setImages(prev => [...prev, ...newImages]); setImages(prev => [...prev, ...newImages]);
} catch { } catch {
setImgError("Не удалось обработать изображение"); setImgError("Failed to process image");
} finally { } finally {
setCompressing(false); setCompressing(false);
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";

View File

@@ -22,7 +22,7 @@ export function useUpdatePost() {
if (!res.ok) throw new Error(`Ошибка ${res.status}`); if (!res.ok) throw new Error(`Ошибка ${res.status}`);
return await res.json(); return await res.json();
} catch (err) { } catch (err) {
setError(err.message || "Не удалось сохранить"); setError(err.message || "Failed to save");
return null; return null;
} finally { } finally {
setLoading(false); setLoading(false);

View File

@@ -46,7 +46,7 @@ export function useUpdateProfile() {
const updatedUser = await res.json(); const updatedUser = await res.json();
return updatedUser; return updatedUser;
} catch (err) { } catch (err) {
setError(err.message || "Не удалось сохранить профиль"); setError(err.message || "Failed to save profile");
return null; return null;
} finally { } finally {
setLoading(false); setLoading(false);

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import './i18n';
import App from './App'; import App from './App';
import { UnreadProvider } from './context/UnreadContext'; import { UnreadProvider } from './context/UnreadContext';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';

View File

@@ -7,8 +7,10 @@ import { getPostForChat } from "../stores/chatPostStore";
import { API_URL } from "../config/authConstants"; import { API_URL } from "../config/authConstants";
import { useDeleteChat } from "../hooks/useDeleteChat"; import { useDeleteChat } from "../hooks/useDeleteChat";
import TrashIcon from "../components/icons/TrashIcon"; import TrashIcon from "../components/icons/TrashIcon";
import { useTranslation } from 'react-i18next';
export default function ChatListPage() { export default function ChatListPage() {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { chats, loading, fetchChats } = useChats(); const { chats, loading, fetchChats } = useChats();
const { isAuthenticated } = useAuthContext(); const { isAuthenticated } = useAuthContext();
@@ -28,10 +30,10 @@ export default function ChatListPage() {
}, [chats, updateFromChats]); }, [chats, updateFromChats]);
const handleDeleteChat = useCallback(async (chatId) => { const handleDeleteChat = useCallback(async (chatId) => {
if (!window.confirm("Вы уверены, что хотите удалить этот чат?")) return; if (!window.confirm(t('chat.confirmDeleteChat'))) return;
const ok = await deleteChat(chatId); const ok = await deleteChat(chatId);
if (ok) fetchChats(); if (ok) fetchChats();
}, [deleteChat, fetchChats]); }, [t, deleteChat, fetchChats]);
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
@@ -41,10 +43,10 @@ export default function ChatListPage() {
<button className="chat-back-btn" onClick={() => navigate("/feed")}> <button className="chat-back-btn" onClick={() => navigate("/feed")}>
← ←
</button> </button>
<div className="chat-header-title">Чаты</div> <div className="chat-header-title">{t('chat.chats')}</div>
<div /> <div />
</div> </div>
<div className="chat-empty-full">Войдите, чтобы видеть чаты</div> <div className="chat-empty-full">{t('chat.loginToSee')}</div>
</div> </div>
</div> </div>
); );
@@ -57,22 +59,22 @@ export default function ChatListPage() {
<button className="chat-back-btn" onClick={() => navigate("/feed")}> <button className="chat-back-btn" onClick={() => navigate("/feed")}>
← ←
</button> </button>
<div className="chat-header-title">Чаты</div> <div className="chat-header-title">{t('chat.chats')}</div>
<div /> <div />
</div> </div>
<div className="chat-list"> <div className="chat-list">
{loading && ( {loading && (
<div className="chat-empty-full">Загрузка...</div> <div className="chat-empty-full">{t('chat.loading')}</div>
)} )}
{!loading && chats.length === 0 && ( {!loading && chats.length === 0 && (
<div className="chat-empty-full">У вас пока нет чатов</div> <div className="chat-empty-full">{t('chat.noChats')}</div>
)} )}
{!loading && chats.map((chat) => { {!loading && chats.map((chat) => {
const post = getPostForChat(chat.id); const post = getPostForChat(chat.id);
const title = post?.title || chat.otherUser?.name || "Пользователь"; const title = post?.title || chat.otherUser?.name || t('chat.user');
const imgSrc = post?.images?.[0] ? `${API_URL}${post.images[0]}` : null; const imgSrc = post?.images?.[0] ? `${API_URL}${post.images[0]}` : null;
const emoji = post?.emoji; const emoji = post?.emoji;
const unreadCount = unreadMap[chat.id] ?? chat.unreadCount ?? chat.unreadMessages ?? 0; const unreadCount = unreadMap[chat.id] ?? chat.unreadCount ?? chat.unreadMessages ?? 0;
@@ -92,7 +94,7 @@ export default function ChatListPage() {
<div className="chat-list-info"> <div className="chat-list-info">
<div className="chat-list-name">{title}</div> <div className="chat-list-name">{title}</div>
<div className="chat-list-preview"> <div className="chat-list-preview">
{chat.lastMessage?.content || "Нет сообщений"} {chat.lastMessage?.content || t('chat.noMessages')}
</div> </div>
</div> </div>
{unreadCount > 0 && ( {unreadCount > 0 && (
@@ -100,7 +102,7 @@ export default function ChatListPage() {
)} )}
<button <button
className="chat-list-delete-btn" className="chat-list-delete-btn"
title="Удалить чат" title={t('chat.deleteChat')}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteChat(chat.id); handleDeleteChat(chat.id);

View File

@@ -8,8 +8,10 @@ import { useUnread } from "../context/UnreadContext";
import { API_URL } from "../config/authConstants"; import { API_URL } from "../config/authConstants";
import Picker from "@emoji-mart/react"; import Picker from "@emoji-mart/react";
import emojiData from "@emoji-mart/data"; import emojiData from "@emoji-mart/data";
import { useTranslation } from 'react-i18next';
export default function ChatPage() { export default function ChatPage() {
const { t } = useTranslation();
const { chatId } = useParams(); const { chatId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { fetchMessages, markAsRead } = useChats(); const { fetchMessages, markAsRead } = useChats();
@@ -83,15 +85,15 @@ export default function ChatPage() {
post?.emoji || "📋" post?.emoji || "📋"
)} )}
</div> </div>
<div className="chat-header-title">{post?.title || "Чат"}</div> <div className="chat-header-title">{post?.title || t('chat.title')}</div>
<div className={`chat-status${connected ? " connected" : ""}`}> <div className={`chat-status${connected ? " connected" : ""}`}>
{connected ? "В сети" : "Отключено"} {connected ? t('chat.online') : t('chat.offline')}
</div> </div>
</div> </div>
<div className="chat-messages"> <div className="chat-messages">
{messages.length === 0 && ( {messages.length === 0 && (
<div className="chat-empty">Нет сообщений</div> <div className="chat-empty">{t('chat.noMessages')}</div>
)} )}
{messages.map((m) => ( {messages.map((m) => (
<div <div
@@ -123,7 +125,7 @@ export default function ChatPage() {
<input <input
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
placeholder="Сообщение..." placeholder={t('chat.messagePlaceholder')}
className="chat-input" className="chat-input"
/> />
<button <button

View File

@@ -16,6 +16,7 @@ import { useAuthContext } from "../context/AuthContext";
import { useDeletePost } from "../hooks/useDeletePost"; import { useDeletePost } from "../hooks/useDeletePost";
import { useFavorites } from "../hooks/useFavorites"; import { useFavorites } from "../hooks/useFavorites";
import { usePostStatus } from "../hooks/usePostStatus"; import { usePostStatus } from "../hooks/usePostStatus";
import { useTranslation } from 'react-i18next';
/** /**
* MainFeed — корневой компонент приложения. * MainFeed — корневой компонент приложения.
@@ -29,11 +30,12 @@ export default function MainFeed() {
const { deletePost } = useDeletePost(); const { deletePost } = useDeletePost();
const { toggleFavorite, loadFavorites } = useFavorites(); const { toggleFavorite, loadFavorites } = useFavorites();
const { markAsFound } = usePostStatus(); const { markAsFound } = usePostStatus();
const { t } = useTranslation();
// Открыт ли левый сайдбар (мобильный слайд-аут) // Открыт ли левый сайдбар (мобильный слайд-аут)
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
// Активный пункт навигации в сайдбаре // Активный пункт навигации в сайдбаре
const [activeNav, setActiveNav] = useState("Главная"); const [activeNav, setActiveNav] = useState("home");
// Фильтр по типу объявления: null (все) | "loss" | "found" // Фильтр по типу объявления: null (все) | "loss" | "found"
const [typeFilter, setTypeFilter] = useState(null); const [typeFilter, setTypeFilter] = useState(null);
// Поисковый запрос (фильтр по title / description) // Поисковый запрос (фильтр по title / description)
@@ -109,7 +111,7 @@ export default function MainFeed() {
if (search) params.set("search", search); if (search) params.set("search", search);
const res = await fetch(`${API_URL}/api/v1/posts?${params}`); const res = await fetch(`${API_URL}/api/v1/posts?${params}`);
if (!res.ok) throw new Error(`Ошибка ${res.status}`); if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json(); const data = await res.json();
const content = data.content ?? []; const content = data.content ?? [];
@@ -122,7 +124,7 @@ export default function MainFeed() {
setHasMore(!data.last); // data.last — стандартное поле Spring Page setHasMore(!data.last); // data.last — стандартное поле Spring Page
setPage(pageNum); setPage(pageNum);
} catch (err) { } catch (err) {
setError(err.message || "Не удалось загрузить объявления"); setError(err.message || "Failed to load ads");
} finally { } finally {
setLoading(false); setLoading(false);
setLoadingMore(false); setLoadingMore(false);
@@ -169,7 +171,7 @@ export default function MainFeed() {
setMyAds([]); setMyAds([]);
return; return;
} }
if (!res.ok) throw new Error(`Ошибка ${res.status}`); if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json(); const data = await res.json();
const posts = Array.isArray(data) ? data : (data.items ?? []); const posts = Array.isArray(data) ? data : (data.items ?? []);
const normalizedPosts = posts.map(ad => ({ const normalizedPosts = posts.map(ad => ({
@@ -178,7 +180,7 @@ export default function MainFeed() {
})); }));
setMyAds(normalizedPosts); setMyAds(normalizedPosts);
} catch (err) { } catch (err) {
setError(err.message || "Не удалось загрузить ваши объявления"); setError(err.message || "Failed to load your ads");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -197,7 +199,7 @@ export default function MainFeed() {
if (success) { if (success) {
// Перезагружаем ленту для получения обновлённых данных // Перезагружаем ленту для получения обновлённых данных
loadFeed(); loadFeed();
if (activeNav === "Мои объявления") { if (activeNav === "myads") {
loadMyPosts(); loadMyPosts();
} }
} }
@@ -205,7 +207,7 @@ export default function MainFeed() {
/** Удаляет объявление и перезагружает ленту */ /** Удаляет объявление и перезагружает ленту */
const handleDelete = useCallback(async (postId) => { const handleDelete = useCallback(async (postId) => {
if (!window.confirm("Вы уверены, что хотите удалить это объявление?")) { if (!window.confirm(t('feed.confirmDelete'))) {
return; return;
} }
const success = await deletePost(postId); const success = await deletePost(postId);
@@ -213,11 +215,11 @@ export default function MainFeed() {
loadFeed(); loadFeed();
loadMyPosts(); loadMyPosts();
} }
}, [deletePost, loadFeed, loadMyPosts]); }, [t, deletePost, loadFeed, loadMyPosts]);
/** Отмечает объявление как "Вещь найдена" и перезагружает ленту */ /** Отмечает объявление как "Вещь найдена" и перезагружает ленту */
const handleMarkFound = useCallback(async (postId) => { const handleMarkFound = useCallback(async (postId) => {
if (!window.confirm("Отметить это объявление как \"Вещь найдена\"?")) { if (!window.confirm(t('feed.confirmMarkFound'))) {
return; return;
} }
@@ -226,7 +228,7 @@ export default function MainFeed() {
loadFeed(); loadFeed();
loadMyPosts(); loadMyPosts();
} }
}, [markAsFound, token, loadFeed, loadMyPosts]); }, [t, markAsFound, token, loadFeed, loadMyPosts]);
// После создания или редактирования объявления перезагружаем ленту, // После создания или редактирования объявления перезагружаем ленту,
// чтобы получить актуальные данные (включая изображения) из БД // чтобы получить актуальные данные (включая изображения) из БД
@@ -280,7 +282,7 @@ export default function MainFeed() {
// Когда переключаемся на «Мои объявления» — подгружаем с бэкенда по userId // Когда переключаемся на «Мои объявления» — подгружаем с бэкенда по userId
useEffect(() => { useEffect(() => {
if (activeNav === "Мои объявления" && isAuthenticated && user?.id) { if (activeNav === "myads" && isAuthenticated && user?.id) {
loadMyPosts(); loadMyPosts();
} }
}, [activeNav, isAuthenticated, user?.id, loadMyPosts]); }, [activeNav, isAuthenticated, user?.id, loadMyPosts]);
@@ -289,16 +291,16 @@ export default function MainFeed() {
const filteredAds = (() => { const filteredAds = (() => {
let result; let result;
if (activeNav === "Мои объявления") { if (activeNav === "myads") {
// Используем объявления, загруженные с бэкенда по userId // Используем объявления, загруженные с бэкенда по userId
result = myAds; result = myAds;
} else if (activeNav === "Главная") { } else if (activeNav === "home") {
result = ads; result = ads;
} else { } else {
result = ads.filter(ad => result = ads.filter(ad =>
activeNav === "Вещи" activeNav === "items"
? ["Вещь", "Другое"].includes(ad.category) ? ["Вещь", "Другое"].includes(ad.category)
: activeNav === "Питомцы" : activeNav === "pets"
? ad.category === "Питомец" ? ad.category === "Питомец"
: false : false
); );
@@ -329,7 +331,7 @@ export default function MainFeed() {
<div className="f-center"> <div className="f-center">
<div className="f-spinner-wrap"> <div className="f-spinner-wrap">
<div className="f-spinner" /> <div className="f-spinner" />
Загружаем объявления... {t('feed.loading')}
</div> </div>
</div> </div>
); );
@@ -338,15 +340,15 @@ export default function MainFeed() {
return ( return (
<div className="f-center"> <div className="f-center">
<div className="f-center-icon">⚠️</div> <div className="f-center-icon">⚠️</div>
<div className="f-center-title">Не удалось загрузить ленту</div> <div className="f-center-title">{t('feed.loadError')}</div>
<div className="f-center-sub">{error}</div> <div className="f-center-sub">{error}</div>
</div> </div>
); );
} }
if (!filteredAds.length) { if (!filteredAds.length) {
const emptyMsg = activeNav === "Мои объявления" const emptyMsg = activeNav === "myads"
? { title: "У вас нет объявлений", sub: "Создайте первое — нажмите кнопку выше" } ? { title: t('feed.emptyMyAdsTitle'), sub: t('feed.emptyMyAdsSub') }
: { title: "Объявлений пока нет", sub: "Будьте первым — разместите объявление<br />о потере или находке" }; : { title: t('feed.emptyAllTitle'), sub: t('feed.emptyAllSub') };
return ( return (
<div className="f-center"> <div className="f-center">
@@ -364,10 +366,10 @@ export default function MainFeed() {
ad={ad} ad={ad}
liked={liked.has(ad.id)} liked={liked.has(ad.id)}
onLike={toggleLike} onLike={toggleLike}
onEdit={activeNav === "Мои объявления" ? handleEdit : undefined} onEdit={activeNav === "myads" ? handleEdit : undefined}
onDelete={activeNav === "Мои объявления" ? handleDelete : undefined} onDelete={activeNav === "myads" ? handleDelete : undefined}
onMarkFound={activeNav === "Мои объявления" ? handleMarkFound : undefined} onMarkFound={activeNav === "myads" ? handleMarkFound : undefined}
isOwner={activeNav === "Мои объявления"} isOwner={activeNav === "myads"}
onImageClick={handleImageClick} onImageClick={handleImageClick}
onAuthRequired={() => setAuthModalOpen(true)} onAuthRequired={() => setAuthModalOpen(true)}
/> />
@@ -402,7 +404,7 @@ export default function MainFeed() {
fontWeight: 900, fontWeight: 900,
lineHeight: 1 lineHeight: 1
}}>+</span> }}>+</span>
Разместить объявление {t('feed.createPost')}
</button> </button>
</div> </div>
{/* Иконка профиля в правом углу */} {/* Иконка профиля в правом углу */}
@@ -416,7 +418,7 @@ export default function MainFeed() {
) : ( ) : (
<button className="f-login-btn" <button className="f-login-btn"
onClick={() => setAuthModalOpen(true)}> onClick={() => setAuthModalOpen(true)}>
Войти {t('feed.login')}
</button> </button>
)} )}
</div> </div>
@@ -432,7 +434,7 @@ export default function MainFeed() {
<input <input
className="f-search-input" className="f-search-input"
type="text" type="text"
placeholder="Поиск по названию или описанию..." placeholder={t('feed.searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} onChange={e => setSearchQuery(e.target.value)}
/> />
@@ -455,21 +457,21 @@ export default function MainFeed() {
: ""}`} : ""}`}
onClick={() => setTypeFilter("loss")} onClick={() => setTypeFilter("loss")}
> >
🔴 Потери {t('feed.filterLoss')}
</button> </button>
<button <button
className={`f-filter-btn${typeFilter === null ? " active" className={`f-filter-btn${typeFilter === null ? " active"
: ""}`} : ""}`}
onClick={() => setTypeFilter(null)} onClick={() => setTypeFilter(null)}
> >
Все объявления {t('feed.filterAll')}
</button> </button>
<button <button
className={`f-filter-btn${typeFilter === "found" ? " active" className={`f-filter-btn${typeFilter === "found" ? " active"
: ""}`} : ""}`}
onClick={() => setTypeFilter("found")} onClick={() => setTypeFilter("found")}
> >
🟢 Находки {t('feed.filterFound')}
</button> </button>
</div> </div>
@@ -477,7 +479,7 @@ export default function MainFeed() {
<div className="f-wrap"> <div className="f-wrap">
<div className="f-feed"> <div className="f-feed">
{renderContent()} {renderContent()}
{activeNav === "Главная" && !loading && ( {activeNav === "home" && !loading && (
<div ref={sentinelRef} style={{height: "1px"}}/> <div ref={sentinelRef} style={{height: "1px"}}/>
)} )}
{loadingMore && ( {loadingMore && (

View File

@@ -8,6 +8,7 @@ import ProfileAvatar from "../components/profile/ProfileAvatar";
import { ProfileModal } from "../components/profile"; import { ProfileModal } from "../components/profile";
import AuthModal from "../components/AuthModal"; import AuthModal from "../components/AuthModal";
import "../styles/reviews.css"; import "../styles/reviews.css";
import { useTranslation } from 'react-i18next';
/* ── Star rating sub-component ── */ /* ── Star rating sub-component ── */
function StarRating({ value, onChange, size = 28, readOnly = false }) { function StarRating({ value, onChange, size = 28, readOnly = false }) {
@@ -43,6 +44,7 @@ function StarRating({ value, onChange, size = 28, readOnly = false }) {
/* ── Single review card ── */ /* ── Single review card ── */
function ReviewCard({ review }) { function ReviewCard({ review }) {
const { t } = useTranslation();
const date = review.createdAt const date = review.createdAt
? new Date(review.createdAt).toLocaleDateString("ru-RU", { ? new Date(review.createdAt).toLocaleDateString("ru-RU", {
day: "numeric", month: "long", year: "numeric", day: "numeric", month: "long", year: "numeric",
@@ -56,7 +58,7 @@ function ReviewCard({ review }) {
{review.authorName ? review.authorName.charAt(0).toUpperCase() : "?"} {review.authorName ? review.authorName.charAt(0).toUpperCase() : "?"}
</div> </div>
<div className="rv-card-meta"> <div className="rv-card-meta">
<span className="rv-card-name">{review.authorName || "Аноним"}</span> <span className="rv-card-name">{review.authorName || t('reviews.anonymous')}</span>
{date && <span className="rv-card-date">{date}</span>} {date && <span className="rv-card-date">{date}</span>}
</div> </div>
<div className="rv-card-rating"> <div className="rv-card-rating">
@@ -71,6 +73,7 @@ function ReviewCard({ review }) {
/* ── Review form ── */ /* ── Review form ── */
function ReviewForm({ onSubmit, submitting }) { function ReviewForm({ onSubmit, submitting }) {
const { t } = useTranslation();
const [rating, setRating] = useState(0); const [rating, setRating] = useState(0);
const [text, setText] = useState(""); const [text, setText] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -78,7 +81,7 @@ function ReviewForm({ onSubmit, submitting }) {
const handleSubmit = (e) => { const handleSubmit = (e) => {
e.preventDefault(); e.preventDefault();
if (rating === 0) { if (rating === 0) {
setError("Поставьте оценку"); setError(t('reviews.ratePrompt'));
return; return;
} }
setError(""); setError("");
@@ -89,15 +92,15 @@ function ReviewForm({ onSubmit, submitting }) {
return ( return (
<form className="rv-form" onSubmit={handleSubmit}> <form className="rv-form" onSubmit={handleSubmit}>
<h3 className="rv-form-title">Оставить отзыв</h3> <h3 className="rv-form-title">{t('reviews.leaveReview')}</h3>
<div className="rv-form-rating"> <div className="rv-form-rating">
<span className="rv-form-label">Оценка:</span> <span className="rv-form-label">{t('reviews.rating')}</span>
<StarRating value={rating} onChange={setRating} size={32} /> <StarRating value={rating} onChange={setRating} size={32} />
</div> </div>
{error && <div className="rv-form-error">{error}</div>} {error && <div className="rv-form-error">{error}</div>}
<textarea <textarea
className="rv-form-textarea" className="rv-form-textarea"
placeholder="Напишите ваш отзыв (необязательно)..." placeholder={t('reviews.reviewPlaceholder')}
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
maxLength={500} maxLength={500}
@@ -106,7 +109,7 @@ function ReviewForm({ onSubmit, submitting }) {
<div className="rv-form-footer"> <div className="rv-form-footer">
<span className="rv-form-counter">{text.length}/500</span> <span className="rv-form-counter">{text.length}/500</span>
<button className="rv-form-submit" type="submit" disabled={submitting}> <button className="rv-form-submit" type="submit" disabled={submitting}>
{submitting ? "Отправка..." : "Отправить"} {submitting ? t('reviews.sending') : t('reviews.send')}
</button> </button>
</div> </div>
</form> </form>
@@ -115,13 +118,14 @@ function ReviewForm({ onSubmit, submitting }) {
/* ── Main Reviews page ── */ /* ── Main Reviews page ── */
export default function ReviewsPage() { export default function ReviewsPage() {
const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, isAuthenticated } = useAuthContext(); const { user, isAuthenticated } = useAuthContext();
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [profileModalOpen, setProfileModalOpen] = useState(false); const [profileModalOpen, setProfileModalOpen] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false); const [authModalOpen, setAuthModalOpen] = useState(false);
const [activeNav] = useState("Отзывы"); const [activeNav] = useState("reviews");
const [reviews, setReviews] = useState([]); const [reviews, setReviews] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -149,7 +153,7 @@ export default function ReviewsPage() {
console.log('[Reviews] GET request to:', `${API_URL}/api/v1/reviews`); console.log('[Reviews] GET request to:', `${API_URL}/api/v1/reviews`);
const res = await fetch(`${API_URL}/api/v1/reviews`); const res = await fetch(`${API_URL}/api/v1/reviews`);
console.log('[Reviews] Response status:', res.status, res.ok); console.log('[Reviews] Response status:', res.status, res.ok);
if (!res.ok) throw new Error(`Ошибка ${res.status}`); if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json(); const data = await res.json();
console.log('[Reviews] Response data:', data); console.log('[Reviews] Response data:', data);
const list = Array.isArray(data) ? data : data.items ?? data.reviews ?? []; const list = Array.isArray(data) ? data : data.items ?? data.reviews ?? [];
@@ -157,7 +161,7 @@ export default function ReviewsPage() {
setReviews(list); setReviews(list);
} catch (err) { } catch (err) {
console.error('[Reviews] Error:', err); console.error('[Reviews] Error:', err);
setError(err.message || "Не удалось загрузить отзывы"); setError(err.message || "Failed to load reviews");
// Show empty state with mock data hint // Show empty state with mock data hint
setReviews([]); setReviews([]);
} finally { } finally {
@@ -190,7 +194,7 @@ export default function ReviewsPage() {
const responseData = await res.json(); const responseData = await res.json();
console.log('[Reviews] POST response data:', responseData); console.log('[Reviews] POST response data:', responseData);
} }
if (!res.ok) throw new Error(`Ошибка ${res.status}`); if (!res.ok) throw new Error(`Error ${res.status}`);
// Reload reviews after successful submit // Reload reviews after successful submit
await loadReviews(); await loadReviews();
} catch (err) { } catch (err) {
@@ -198,7 +202,7 @@ export default function ReviewsPage() {
setReviews((prev) => [ setReviews((prev) => [
{ {
id: Date.now(), id: Date.now(),
authorName: profileData.firstName || "Вы", authorName: profileData.firstName || t('reviews.you'),
rating, rating,
text, text,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
@@ -208,7 +212,7 @@ export default function ReviewsPage() {
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}, [isAuthenticated, loadReviews, profileData.firstName]); }, [t, isAuthenticated, loadReviews, profileData.firstName]);
/** Average rating */ /** Average rating */
const avgRating = reviews.length const avgRating = reviews.length
@@ -245,7 +249,7 @@ export default function ReviewsPage() {
<MenuIcon /> <MenuIcon />
</button> </button>
<div className="f-topbar-center"> <div className="f-topbar-center">
<h1 className="rv-page-title">Отзывы</h1> <h1 className="rv-page-title">{t('reviews.title')}</h1>
</div> </div>
{/* Иконка профиля в правом углу */} {/* Иконка профиля в правом углу */}
{isAuthenticated ? ( {isAuthenticated ? (
@@ -254,7 +258,7 @@ export default function ReviewsPage() {
</div> </div>
) : ( ) : (
<button className="f-login-btn" onClick={() => setAuthModalOpen(true)}> <button className="f-login-btn" onClick={() => setAuthModalOpen(true)}>
Войти {t('reviews.login')}
</button> </button>
)} )}
</div> </div>
@@ -271,11 +275,11 @@ export default function ReviewsPage() {
<StarRating value={Math.round(Number(avgRating))} readOnly size={22} /> <StarRating value={Math.round(Number(avgRating))} readOnly size={22} />
</> </>
) : ( ) : (
<span className="rv-summary-none">Пока нет отзывов</span> <span className="rv-summary-none">{t('reviews.noReviews')}</span>
)} )}
</div> </div>
<span className="rv-summary-total"> <span className="rv-summary-total">
{reviews.length} {reviews.length === 1 ? "отзыв" : reviews.length < 5 ? "отзыва" : "отзывов"} {t('reviews.title')} ({reviews.length})
</span> </span>
{/* Distribution bars */} {/* Distribution bars */}
@@ -299,9 +303,9 @@ export default function ReviewsPage() {
<ReviewForm onSubmit={handleSubmitReview} submitting={submitting} /> <ReviewForm onSubmit={handleSubmitReview} submitting={submitting} />
) : ( ) : (
<div className="rv-login-prompt"> <div className="rv-login-prompt">
<p>Войдите, чтобы оставить отзыв</p> <p>{t('reviews.loginPrompt')}</p>
<button className="rv-login-btn" onClick={() => setAuthModalOpen(true)}> <button className="rv-login-btn" onClick={() => setAuthModalOpen(true)}>
Войти {t('reviews.login')}
</button> </button>
</div> </div>
)} )}
@@ -313,14 +317,14 @@ export default function ReviewsPage() {
<div className="f-center"> <div className="f-center">
<div className="f-spinner-wrap"> <div className="f-spinner-wrap">
<div className="f-spinner" /> <div className="f-spinner" />
Загружаем отзывы... {t('reviews.loading')}
</div> </div>
</div> </div>
) : error && reviews.length === 0 ? ( ) : error && reviews.length === 0 ? (
<div className="f-center"> <div className="f-center">
<div className="f-center-icon">💬</div> <div className="f-center-icon">💬</div>
<div className="f-center-title">Отзывов пока нет</div> <div className="f-center-title">{t('reviews.emptyTitle')}</div>
<div className="f-center-sub">Станьте первым — оставьте отзыв о площадке</div> <div className="f-center-sub">{t('reviews.emptySub')}</div>
</div> </div>
) : ( ) : (
reviews.map((review) => <ReviewCard key={review.id} review={review} />) reviews.map((review) => <ReviewCard key={review.id} review={review} />)

View File

@@ -102,6 +102,32 @@
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
.nk-nav-right {
display: flex;
align-items: center;
gap: 10px;
}
.nk-lang-btn {
background: rgba(244, 247, 246, 0.06);
border: 1px solid rgba(244, 247, 246, 0.12);
color: #F4F7F6;
padding: 9px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
backdrop-filter: blur(6px);
transition: background 0.2s;
}
.nk-lang-btn:hover {
background: rgba(244, 247, 246, 0.12);
}
.nk-nav-btn { .nk-nav-btn {
background: rgba(244, 247, 246, 0.08); background: rgba(244, 247, 246, 0.08);
border: 1px solid rgba(244, 247, 246, 0.16); border: 1px solid rgba(244, 247, 246, 0.16);

View File

@@ -1,14 +1,10 @@
/** import i18n from '../i18n';
* Валидирует поля формы объявления на стороне клиента.
*
* @param {object} form — текущие значения формы
* @returns {object} — объект ошибок; пустой = форма валидна
*/
export function validatePostForm(form) { export function validatePostForm(form) {
const errors = {}; const errors = {};
if (!form.title.trim()) errors.title = "Введите название"; if (!form.title.trim()) errors.title = i18n.t('validation.titleRequired');
if (!form.description.trim()) errors.description = "Введите описание"; if (!form.description.trim()) errors.description = i18n.t('validation.descriptionRequired');
if (!form.category) errors.category = "Выберите категорию"; if (!form.category) errors.category = i18n.t('validation.categoryRequired');
if (!form.phone.trim()) errors.phone = "Введите номер телефона"; if (!form.phone.trim()) errors.phone = i18n.t('validation.phoneRequired');
return errors; return errors;
} }