Merge pull request #31 from SlimusMinus/add_lang
added english language
This commit is contained in:
474
package-lock.json
generated
474
package-lock.json
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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")} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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" : ""}`}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}>
|
||||||
</button>
|
<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>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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} />)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user