Merge pull request #31 from SlimusMinus/add_lang

added english language
This commit is contained in:
SlimusMinus
2026-07-09 00:10:48 +03:00
committed by GitHub
33 changed files with 454 additions and 687 deletions

474
package-lock.json generated
View File

@@ -22,12 +22,15 @@
"autoprefixer": "^10.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"framer-motion": "^12.38.0",
"i18next": "^22.5.1",
"lucide-react": "^1.11.0",
"postcss": "^8.5.12",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.74.0",
"react-i18next": "^12.3.1",
"react-router-dom": "^7.14.2",
"react-scripts": "5.0.1",
"sockjs-client": "^1.6.1",
@@ -3037,16 +3040,6 @@
"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": {
"version": "1.2.5",
"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": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@@ -7556,8 +7285,7 @@
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
@@ -9695,6 +9423,15 @@
"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": {
"version": "5.6.7",
"resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.7.tgz",
@@ -9852,6 +9589,29 @@
"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": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -14873,6 +14633,28 @@
"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": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -15468,40 +15250,6 @@
"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": {
"version": "2.80.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz",
@@ -17346,20 +17094,6 @@
"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": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
@@ -17599,95 +17333,13 @@
"node": ">= 0.8"
}
},
"node_modules/vite": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"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": {
"node": "^20.19.0 || >=22.12.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": ">=0.10.0"
}
},
"node_modules/w3c-hr-time": {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,10 @@
import { useState, useEffect, useRef } from "react";
const MONTHS = ["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"];
const DAYS = ["Пн","Вт","Ср","Чт","Пт","Сб","Вс"];
import { useTranslation } from 'react-i18next';
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 d = new Date(date);
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-cal-header">
<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>
</div>
<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) =>
day ? (
<div key={i}

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import {NavIcons} from "./icons/NavIcons";
import { useNavigate } from "react-router-dom";
import { useTheme } from "../context/ThemeContext";
import { useUnread } from "../context/UnreadContext";
import { useTranslation } from 'react-i18next';
export default function Sidebar({
open,
@@ -18,40 +19,48 @@ export default function Sidebar({
const navigate = useNavigate();
const { theme, toggle: toggleTheme } = useTheme();
const { totalUnread } = useUnread();
const { t, i18n } = useTranslation();
const toggleLang = () => {
const next = i18n.language === 'ru' ? 'en' : 'ru';
i18n.changeLanguage(next);
localStorage.setItem('i18nextLng', next);
};
return (
<>
<div className={`f-overlay${open ? " show" : ""}`} onClick={onClose} />
<div className={`f-sidebar${open ? " open" : ""}`}>
<div className="f-sb-head">
<div className="f-sb-logo"><LogoIcon /></div>
<span className="f-sb-name">Находка</span>
<span className="f-sb-name">{t('app.name')}</span>
</div>
<nav className="f-sb-nav">
{NAV_ITEMS.map(({ icon, label }) => {
{NAV_ITEMS.map(({ icon, key: itemKey, labelKey }) => {
const Ic = NavIcons[icon];
const handleClick = () => {
// «Мои объявления» требует авторизации
if (!isAuthenticated && label === "Мои объявления") {
if (!isAuthenticated && itemKey === "myads") {
onLoginClick();
} else if (label === "Чат") {
} else if (itemKey === "chat") {
if (!isAuthenticated) {
onLoginClick();
} else {
navigate("/chats");
}
} else {
onNavClick(label);
onNavClick(itemKey);
}
onClose();
};
return (
<div
key={label}
className={`f-sb-item${activeNav === label ? " active" : ""}`}
key={itemKey}
className={`f-sb-item${activeNav === itemKey ? " active" : ""}`}
onClick={handleClick}
>
<Ic /> {label}
{label === "Чат" && totalUnread > 0 && (
<Ic /> {t(labelKey)}
{itemKey === "chat" && totalUnread > 0 && (
<span className="f-sb-unread-badge">{totalUnread > 99 ? "99+" : totalUnread}</span>
)}
</div>
@@ -59,29 +68,29 @@ export default function Sidebar({
})}
</nav>
<div className="f-sb-bottom">
{NAV_BOTTOM.map(({ icon, label }) => {
{NAV_BOTTOM.map(({ icon, key: itemKey, labelKey }) => {
const Ic = NavIcons[icon];
const handleBottomClick = () => {
if (label === "Правила площадки") {
if (itemKey === "rules") {
onRulesClick?.();
onClose();
return;
}
if (label === "Отзывы") {
if (itemKey === "reviews") {
onReviewsClick?.();
onClose();
return;
}
onNavClick(label);
onNavClick(itemKey);
onClose();
};
return (
<div
key={label}
className={`f-sb-item${activeNav === label ? " active" : ""}`}
key={itemKey}
className={`f-sb-item${activeNav === itemKey ? " active" : ""}`}
onClick={handleBottomClick}
>
<Ic /> {label}
<Ic /> {t(labelKey)}
</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"/>
</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>

View File

@@ -1,9 +1,12 @@
import { useTranslation } from 'react-i18next';
export default function FinalCta({ onClick }) {
const { t } = useTranslation();
return (
<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}>
Открыть Находку
{t('landing.openNakhodka')}
</button>
</section>
);

View File

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

View File

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

View File

@@ -1,10 +1,29 @@
import { useTranslation } from 'react-i18next';
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 (
<nav className="nk-nav">
<span className="nk-logo">Находка</span>
<button className="nk-nav-btn" onClick={onLoginClick}>
Войти
</button>
<span className="nk-logo">{t('app.name')}</span>
<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 className="nk-nav-btn" onClick={onLoginClick}>
{t('landing.login')}
</button>
</div>
</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() {
const { t } = useTranslation();
return (
<section className="nk-steps-section">
<h2 className="nk-h2">Как это работает</h2>
<h2 className="nk-h2">{t('landing.howItWorks')}</h2>
<div className="nk-steps-grid">
{steps.map((s) => (
<div className="nk-step-card" key={s.n}>
<span className="nk-step-num">{s.n}</span>
<h3 className="nk-step-title">{s.title}</h3>
<p className="nk-step-text">{s.text}</p>
{STEP_KEYS.map((s, i) => (
<div className="nk-step-card" key={i}>
<span className="nk-step-num">{`0${i + 1}`}</span>
<h3 className="nk-step-title">{t(s.titleKey)}</h3>
<p className="nk-step-text">{t(s.textKey)}</p>
</div>
))}
</div>

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,23 @@
import i18n from '../../i18n';
// ─── Form validation logic ──────────────────────────────────────────────────
export function validateProfileForm(form) {
const errors = {};
if (!form.firstName.trim()) {
errors.firstName = "Введите имя";
errors.firstName = i18n.t('profile.validateNameRequired');
}
if (!form.lastName.trim()) {
errors.lastName = "Введите фамилию";
errors.lastName = i18n.t('profile.validateSurnameRequired');
}
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) {
errors.phone = "Некорректный номер телефона";
errors.phone = i18n.t('profile.validatePhoneInvalid');
}
return errors;

View File

@@ -1,22 +1,20 @@
// ─── CONSTANTS ────────────────────────────────────────────────────────────────
export const NAV_ITEMS = [
{ icon: "grid", label: "Главная" },
{ icon: "file", label: "Мои объявления" },
{ icon: "box", label: "Вещи" },
{ icon: "paw", label: "Питомцы" },
{ icon: "chat", label: "Чат" },
{ icon: "grid", key: "home", labelKey: "nav.home" },
{ icon: "file", key: "myads", labelKey: "nav.myAds" },
{ icon: "box", key: "items", labelKey: "nav.items" },
{ icon: "paw", key: "pets", labelKey: "nav.pets" },
{ icon: "chat", key: "chat", labelKey: "nav.chat" },
];
export const NAV_BOTTOM = [
{ icon: "star", label: "Отзывы" },
{ icon: "rules", label: "Правила площадки" },
{ icon: "star", key: "reviews", labelKey: "nav.reviews" },
{ 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 = {
type: "loss",
title: "",
@@ -26,4 +24,4 @@ export const EMPTY_FORM = {
phone: "",
date: "",
address: ""
};
};

View File

@@ -10,7 +10,7 @@ export function useAuth() {
const [error, setError] = useState("");
const login = async (login, password) => {
if (!login || !password) { setError("Введите логин и пароль"); return; }
if (!login || !password) { setError("Enter login and password"); return; }
setLoading(true); setError("");
try {
@@ -22,7 +22,7 @@ export function useAuth() {
if (!res.ok) {
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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -102,6 +102,32 @@
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 {
background: rgba(244, 247, 246, 0.08);
border: 1px solid rgba(244, 247, 246, 0.16);

View File

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