From 7142e26d76e7f7a1fa10503681239286df40aa55 Mon Sep 17 00:00:00 2001 From: izachoc Date: Fri, 17 Apr 2026 00:27:59 +0300 Subject: [PATCH 01/23] feat: setup i18n and polish header --- frontend/package-lock.json | 150 ++++++++++++- frontend/package.json | 4 + frontend/public/locales/en/common.json | 11 + frontend/public/locales/uk/common.json | 11 + frontend/src/App.tsx | 40 ++-- frontend/src/components/Header.tsx | 199 +++++++++++++----- frontend/src/components/ScrollToTop.tsx | 12 ++ .../src/components/ui/LanguageSwitcher.tsx | 47 +++++ frontend/src/i18n/config.ts | 23 ++ frontend/src/main.tsx | 16 +- 10 files changed, 439 insertions(+), 74 deletions(-) create mode 100644 frontend/public/locales/en/common.json create mode 100644 frontend/public/locales/uk/common.json create mode 100644 frontend/src/components/ScrollToTop.tsx create mode 100644 frontend/src/components/ui/LanguageSwitcher.tsx create mode 100644 frontend/src/i18n/config.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 26600a2..5ab2b2c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,12 +18,16 @@ "axios": "1.13.6", "firebase": "^12.10.0", "framer-motion": "^12.38.0", + "i18next": "^26.0.5", + "i18next-browser-languagedetector": "^8.2.1", + "i18next-http-backend": "^3.0.4", "lottie-react": "^2.4.1", "lucide-react": "^1.7.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.72.0", "react-hot-toast": "^2.6.0", + "react-i18next": "^17.0.3", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "socket.io-client": "^4.8.3", @@ -358,7 +362,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3872,6 +3875,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-fetch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", + "integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4839,12 +4851,70 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "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/http-parser-js": { "version": "0.5.10", "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", "license": "MIT" }, + "node_modules/i18next": { + "version": "26.0.5", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.5.tgz", + "integrity": "sha512-9uHb4T27TdV36phJXcbpnRPt5yzAfqHXVrdASvmHZyPuZJtrLythd+GyXhiaHV5LlpuuskbAqhwPjmfTbKbi8w==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz", + "integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.4.tgz", + "integrity": "sha512-udwrBIE6cNpqn1gRAqRULq3+7MzIIuaiKRWrz++dVz5SqWW2VwXmPJtAgkI0JtMLFaADC9qNmnZAxWAhsxXx2g==", + "license": "MIT", + "dependencies": { + "cross-fetch": "4.1.0" + } + }, "node_modules/idb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", @@ -5584,6 +5654,48 @@ "dev": true, "license": "MIT" }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -5891,6 +6003,33 @@ "react-dom": ">=16" } }, + "node_modules/react-i18next": { + "version": "17.0.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.3.tgz", + "integrity": "sha512-x4xjvUNZ56T+zfXWNedNnCET9Xq1IBYWX7IsWo5cCQ/RT+Rm7GWqt0h9PShFi4IhyMnsdiu1C6Jc4DE+/S3PFQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -6729,6 +6868,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 490e05e..02b2987 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,12 +23,16 @@ "axios": "1.13.6", "firebase": "^12.10.0", "framer-motion": "^12.38.0", + "i18next": "^26.0.5", + "i18next-browser-languagedetector": "^8.2.1", + "i18next-http-backend": "^3.0.4", "lottie-react": "^2.4.1", "lucide-react": "^1.7.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.72.0", "react-hot-toast": "^2.6.0", + "react-i18next": "^17.0.3", "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "socket.io-client": "^4.8.3", diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json new file mode 100644 index 0000000..5ff43bc --- /dev/null +++ b/frontend/public/locales/en/common.json @@ -0,0 +1,11 @@ +{ + "nav": { + "tournaments": "Tournaments", + "about": "About Us", + "support": "Support Us", + "contact": "Contact" + }, + "auth": { + "login": "Sign In" + } +} diff --git a/frontend/public/locales/uk/common.json b/frontend/public/locales/uk/common.json new file mode 100644 index 0000000..9f1e5d1 --- /dev/null +++ b/frontend/public/locales/uk/common.json @@ -0,0 +1,11 @@ +{ + "nav": { + "tournaments": "Турніри", + "about": "Про нас", + "support": "Підтримати", + "contact": "Контакти" + }, + "auth": { + "login": "Увійти" + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5e2508d..ee5fe60 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; import { MainLayout } from "./components/MainLayout"; import { Home } from "./pages/Home/Home"; import { Profile } from "./pages/Profile/Profile"; @@ -13,15 +13,24 @@ import { RulesPage } from "./pages/Rules/Rules"; import ProtectedRoute from "./components/ProtectedRoute/ProtectedRoute"; import ForgotPassword from "./pages/Auth/ForgotPassword"; import { RoleRequestPage } from "./pages/GetRole/RoleRequestPage"; -import { Toaster, toast } from 'react-hot-toast'; +import { Toaster, toast } from "react-hot-toast"; import { AuthPage } from "./pages/Auth/AuthPage"; import SignOut from "./pages/Auth/SignOut"; import { useNotificationsSocket } from "./hooks/useNotificationsSocket"; -import { io, Socket } from 'socket.io-client'; +import { io, Socket } from "socket.io-client"; import { auth } from "./firebase"; -import { onAuthStateChanged } from "firebase/auth"; +import { onAuthStateChanged } from "firebase/auth"; import { useEffect, useState } from "react"; +// Утиліта для скролу нагору при кожній зміні URL +const ScrollToTop = () => { + const { pathname } = useLocation(); + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + return null; +}; + export const App = () => { const [socket, setSocket] = useState(null); @@ -32,10 +41,13 @@ export const App = () => { if (user) { const token = await user.getIdToken(); currentSocket = io(import.meta.env.VITE_SOCKETIO_SERVER_URL, { - auth: { token } + auth: { token }, }); currentSocket.on("connect_error", () => { - toast.error("Проблеми з сервером :(. Сповіщення тимчасово не працюють", { id: "socket-error" }); + toast.error( + "Проблеми з сервером :(. Сповіщення тимчасово не працюють", + { id: "socket-error" }, + ); }); currentSocket.on("connect", () => { toast.dismiss("socket-error"); @@ -62,6 +74,7 @@ export const App = () => { <> + {/* <--- Додали сюди! */} {/* Сторінки з Хедером та Футером */} }> @@ -76,11 +89,14 @@ export const App = () => { /> } /> } /> - - - - } /> + + + + } + /> } /> } /> } /> @@ -97,4 +113,4 @@ export const App = () => { ); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 93c0876..468ca53 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,116 +1,199 @@ -import { useState } from "react"; -import { Link, NavLink } from "react-router-dom"; +import { useState, useEffect } from "react"; +import { Link, NavLink, useLocation } from "react-router-dom"; import { useSelector } from "react-redux"; import { type RootState } from "../store"; +import { useTranslation } from "react-i18next"; +import { motion, AnimatePresence } from "framer-motion"; import { ProfileDropdown } from "./ProfileDropdown"; import { NotificationsDropdown } from "./NotificationsDropdown"; +import { LanguageSwitcher } from "./ui/LanguageSwitcher"; export const Header = () => { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [isScrolled, setIsScrolled] = useState(false); + const { t } = useTranslation("common"); + const user = useSelector((s: RootState) => s.user.user); + const location = useLocation(); + + useEffect(() => { + setIsMobileMenuOpen(false); + }, [location.pathname]); + + useEffect(() => { + const handleScroll = () => setIsScrolled(window.scrollY > 20); + window.addEventListener("scroll", handleScroll); + return () => window.removeEventListener("scroll", handleScroll); + }, []); const navItems = [ - { path: "/tournaments", label: "Турніри" }, - { path: "/about-us", label: "Про нас" }, - { path: "/support", label: "Чим ви можете допомогти" }, - { path: "/contact", label: "Контакти" }, + { path: "/tournaments", label: t("nav.tournaments", "Турніри") }, + { path: "/about-us", label: t("nav.about", "Про нас") }, + { path: "/support", label: t("nav.support", "Підтримати") }, + { path: "/contact", label: t("nav.contact", "Контакти") }, ]; - const user = useSelector((s: RootState) => s.user.user); - return ( -
-
+
+
- UGalaxy x Star for Life + UGalaxy{" "} + + x + {" "} + Star for Life -
- {isMobileMenuOpen && ( -
- {navItems.map((item) => ( - setIsMobileMenuOpen(false)} - className={({ isActive }) => - `block font-semibold text-[18px] py-2 transition-colors duration-300 ${isActive ? "text-accent" : "text-white hover:text-accent" - }` - } - > - {item.label} - - ))} + + {isMobileMenuOpen && ( + +
+ {navItems.map((item) => ( + + `block font-semibold text-[18px] transition-all duration-300 ${ + isActive + ? "text-[#fbbf24] translate-x-2" + : "text-white/90 hover:text-white hover:translate-x-2" + }` + } + > + {item.label} + + ))} - {user?.uid ? ( -
- +
+ + + {user?.uid ? ( + + ) : ( + + + {t("auth.login", "Увійти")} + + + )} +
- ) : ( - setIsMobileMenuOpen(false)} - className="btn btn-outline py-2 px-5 mt-2 text-center sm:hidden" - > - Увійти - - )} -
- )} +
+ )} +
); -}; \ No newline at end of file +}; diff --git a/frontend/src/components/ScrollToTop.tsx b/frontend/src/components/ScrollToTop.tsx new file mode 100644 index 0000000..6a001cf --- /dev/null +++ b/frontend/src/components/ScrollToTop.tsx @@ -0,0 +1,12 @@ +import { useEffect } from "react"; +import { useLocation } from "react-router-dom"; + +export const ScrollToTop = () => { + const { pathname } = useLocation(); + + useEffect(() => { + window.scrollTo(0, 0); + }, [pathname]); + + return null; +}; diff --git a/frontend/src/components/ui/LanguageSwitcher.tsx b/frontend/src/components/ui/LanguageSwitcher.tsx new file mode 100644 index 0000000..4f47f52 --- /dev/null +++ b/frontend/src/components/ui/LanguageSwitcher.tsx @@ -0,0 +1,47 @@ +// LanguageSwitcher.tsx +import { useTranslation } from "react-i18next"; +import { motion } from "framer-motion"; + +export const LanguageSwitcher = () => { + const { i18n } = useTranslation(); + + const languages = [ + { code: "uk", label: "UK", flag: "🇺🇦", title: "Українська" }, + { code: "en", label: "EN", flag: "🇬🇧", title: "English" }, + ]; + + return ( +
+ {languages.map((lang) => { + const isActive = i18n.resolvedLanguage === lang.code; + return ( + + ); + })} +
+ ); +}; diff --git a/frontend/src/i18n/config.ts b/frontend/src/i18n/config.ts new file mode 100644 index 0000000..c499c96 --- /dev/null +++ b/frontend/src/i18n/config.ts @@ -0,0 +1,23 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; +import HttpBackend from "i18next-http-backend"; +import LanguageDetector from "i18next-browser-languagedetector"; + +i18n + .use(HttpBackend) + .use(LanguageDetector) + .use(initReactI18next) + .init({ + fallbackLng: "uk", + supportedLngs: ["uk", "en"], + debug: import.meta.env.DEV, + + interpolation: { + escapeValue: false, + }, + backend: { + loadPath: "/locales/{{lng}}/{{ns}}.json", + }, + }); + +export default i18n; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 288fee0..9ade1ce 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,7 +1,7 @@ -import { StrictMode } from "react"; +import { StrictMode, Suspense } from "react"; import { createRoot } from "react-dom/client"; import "./index.css"; -import { ui } from './firebase'; +import { ui } from "./firebase"; import { App } from "./App"; import { FirebaseUIProvider } from "@firebase-oss/ui-react"; import { Provider } from "react-redux"; @@ -9,12 +9,22 @@ import { store } from "./store"; import { QueryClientProvider } from "@tanstack/react-query"; import { queryClient } from "./api/queryClient"; +import "./i18n/config"; + createRoot(document.getElementById("root")!).render( - + + Завантаження мови... +
+ } + > + + From a1a508db70851b62970133d29dfad0178a9ef0dd Mon Sep 17 00:00:00 2001 From: izachoc Date: Fri, 17 Apr 2026 18:46:19 +0300 Subject: [PATCH 02/23] feat: add user avatar and name to header dropdown --- frontend/src/components/ProfileDropdown.tsx | 97 ++++++++++++++++----- 1 file changed, 77 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/ProfileDropdown.tsx b/frontend/src/components/ProfileDropdown.tsx index bb6bec5..256ba1a 100644 --- a/frontend/src/components/ProfileDropdown.tsx +++ b/frontend/src/components/ProfileDropdown.tsx @@ -1,69 +1,126 @@ import { useState, useRef, useEffect } from "react"; import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { auth } from "../firebase"; +import { useSelector } from "react-redux"; +import { type RootState } from "../store"; export const ProfileDropdown = () => { const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); + const { t } = useTranslation("common"); + + // Отримуємо дані юзера + const fbUser = auth.currentUser; + const reduxUser = useSelector((s: RootState) => s.user.user); + + // Формуємо дані для відображення + const photoURL = fbUser?.photoURL; + const displayName = + fbUser?.displayName || reduxUser?.full_name || "Користувач"; + const initial = displayName.charAt(0).toUpperCase(); const toggleMenu = () => setIsOpen(!isOpen); const closeMenu = () => setIsOpen(false); useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { setIsOpen(false); } }; - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; + return () => document.removeEventListener("mousedown", handleClickOutside); }, []); return (
+ {isOpen && (
- - + + - Мій профіль + {t("profile_dropdown.my_profile")} -
- - - + + - Вийти + {t("profile_dropdown.logout")}
)}
); -}; \ No newline at end of file +}; From 7e64f4eb3a06b183ba5387f8cca99cd54fc17f98 Mon Sep 17 00:00:00 2001 From: izachoc Date: Wed, 22 Apr 2026 19:45:13 +0300 Subject: [PATCH 03/23] chore: update dependencies and environment variables --- .env.example | 16 ++++++++-------- frontend/package-lock.json | 10 ++++++---- frontend/package.json | 4 +++- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/.env.example b/.env.example index 421ca22..d147763 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,13 @@ -SECRET_KEY="1234567890" +SECRET_KEY="1234567890" SQLALCHEMY_DATABASE_URI="postgresql://user:password@localhost/dbname" VITE_BACKEND_URL=http://127.0.1:8000 VITE_SOCKETIO_SERVER_URL=http://127.0.0.1:8000 # Налаштування Firebase -VITE_FIREBASE_API_KEY="" -VITE_FIREBASE_AUTH_DOMAIN="" -VITE_FIREBASE_PROJECT_ID="" -VITE_FIREBASE_STORAGE_BUCKET="" -VITE_FIREBASE_MESSAGING_SENDER_ID="" -VITE_FIREBASE_APP_ID="" -VITE_FIREBASE_MEASUREMENT_ID="" +VITE_FIREBASE_API_KEY="AIzaSyA0yV1uvA2ciTRYyed8XL9HgBbhnyouGNE" +VITE_FIREBASE_AUTH_DOMAIN="star-8a692.firebaseapp.com" +VITE_FIREBASE_PROJECT_ID="star-8a692" +VITE_FIREBASE_STORAGE_BUCKET="star-8a692.firebasestorage.app" +VITE_FIREBASE_MESSAGING_SENDER_ID="221685619212" +VITE_FIREBASE_APP_ID="1:221685619212:web:06d14cefaa600a7e3dec29" +VITE_FIREBASE_MEASUREMENT_ID="G-3J22BCL1QQ" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5ab2b2c..f07ea05 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,13 +16,14 @@ "@tanstack/react-query": "^5.95.2", "@tanstack/react-query-devtools": "^5.95.2", "axios": "1.13.6", + "clsx": "^2.1.1", "firebase": "^12.10.0", "framer-motion": "^12.38.0", "i18next": "^26.0.5", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^3.0.4", "lottie-react": "^2.4.1", - "lucide-react": "^1.7.0", + "lucide-react": "^1.8.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.72.0", @@ -31,6 +32,7 @@ "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "socket.io-client": "^4.8.3", + "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", "zod": "^4.3.6" }, @@ -5495,9 +5497,9 @@ } }, "node_modules/lucide-react": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", - "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz", + "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/frontend/package.json b/frontend/package.json index 02b2987..be70c3c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,13 +21,14 @@ "@tanstack/react-query": "^5.95.2", "@tanstack/react-query-devtools": "^5.95.2", "axios": "1.13.6", + "clsx": "^2.1.1", "firebase": "^12.10.0", "framer-motion": "^12.38.0", "i18next": "^26.0.5", "i18next-browser-languagedetector": "^8.2.1", "i18next-http-backend": "^3.0.4", "lottie-react": "^2.4.1", - "lucide-react": "^1.7.0", + "lucide-react": "^1.8.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-hook-form": "^7.72.0", @@ -36,6 +37,7 @@ "react-redux": "^9.2.0", "react-router-dom": "^7.13.1", "socket.io-client": "^4.8.3", + "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", "zod": "^4.3.6" }, From 6022d1f77898fa7d59999cadef898f04bdcfc42f Mon Sep 17 00:00:00 2001 From: izachoc Date: Wed, 22 Apr 2026 19:45:38 +0300 Subject: [PATCH 04/23] feat: setup full i18n support and translation dictionaries --- frontend/public/locales/en/about.json | 0 frontend/public/locales/en/auth.json | 46 +++++++++++++++ frontend/public/locales/en/common.json | 34 ++++++++++- frontend/public/locales/en/contact.json | 0 frontend/public/locales/en/registration.json | 0 frontend/public/locales/en/support.json | 0 frontend/public/locales/en/tournaments.json | 0 frontend/public/locales/uk/about.json | 32 ++++++++++ frontend/public/locales/uk/auth.json | 46 +++++++++++++++ frontend/public/locales/uk/common.json | 34 +++++++++++ frontend/public/locales/uk/contact.json | 19 ++++++ frontend/public/locales/uk/registration.json | 62 ++++++++++++++++++++ frontend/public/locales/uk/support.json | 28 +++++++++ frontend/public/locales/uk/tournaments.json | 33 +++++++++++ frontend/src/i18n/config.ts | 3 + 15 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 frontend/public/locales/en/about.json create mode 100644 frontend/public/locales/en/auth.json create mode 100644 frontend/public/locales/en/contact.json create mode 100644 frontend/public/locales/en/registration.json create mode 100644 frontend/public/locales/en/support.json create mode 100644 frontend/public/locales/en/tournaments.json create mode 100644 frontend/public/locales/uk/about.json create mode 100644 frontend/public/locales/uk/auth.json create mode 100644 frontend/public/locales/uk/contact.json create mode 100644 frontend/public/locales/uk/registration.json create mode 100644 frontend/public/locales/uk/support.json create mode 100644 frontend/public/locales/uk/tournaments.json diff --git a/frontend/public/locales/en/about.json b/frontend/public/locales/en/about.json new file mode 100644 index 0000000..e69de29 diff --git a/frontend/public/locales/en/auth.json b/frontend/public/locales/en/auth.json new file mode 100644 index 0000000..9004e3d --- /dev/null +++ b/frontend/public/locales/en/auth.json @@ -0,0 +1,46 @@ +{ + "slogan": "Your story starts here. Create, embody, change the world.", + "title_login": "Welcome back!", + "title_register": "Create an account", + "subtitle_login": "Continue your journey in UGalaxy", + "subtitle_register": "Ready for new challenges?", + "tabs": { + "login": "Sign in", + "register": "Sign up" + }, + "fields": { + "nickname": { + "label": "Nickname", + "placeholder": "For example, izachoc" + }, + "email": { + "label": "Email", + "placeholder": "name@example.com" + }, + "password": { + "label": "Password", + "placeholder": "Minimum 8 characters", + "forgot": "Forgot password?" + } + }, + "submit": { + "login": "Sign in", + "register": "Sign up", + "google": "Sign in with Google", + "or": "or" + }, + "footer": { + "agree": "you agree to the", + "and": "and", + "terms": "Terms of Use", + "privacy": "Privacy Policy" + }, + "errors": { + "email_invalid": "Invalid email format", + "pass_short": "Minimum 8 characters", + "nick_required": "Nickname is required (minimum 2 characters)", + "email_in_use": "This email is already in use.", + "invalid_creds": "Invalid email or password.", + "unknown": "An error occurred. Please try again." + } +} diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 5ff43bc..2d0ce19 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -2,10 +2,42 @@ "nav": { "tournaments": "Tournaments", "about": "About Us", - "support": "Support Us", + "support": "Support", "contact": "Contact" }, "auth": { "login": "Sign In" + }, + "footer": { + "description": "A place where the best ideas are born. Create, learn, win.", + "platform_title": "Platform", + "info_title": "Information", + "all_tournaments": "All Tournaments", + "rules": "Rules", + "faq": "FAQ", + "about": "About Us", + "contact": "Contact", + "copyright": "© 2026 UGalaxy x Star for Life. All rights reserved." + }, + "profile_dropdown": { + "button": "Profile", + "my_profile": "My Profile", + "logout": "Log Out" + }, + "notifications": { + "title": "Notifications", + "new_count": "{{count}} new", + "empty": "No new notifications" + }, + "errors": { + "404": { + "title": "You've gone beyond the system 👾", + "description": "The page you are looking for has been removed or only exists in a parallel universe. Let's get back to safe territory.", + "back": "Back", + "go_home": "Home" + } + }, + "ui": { + "loading": "Loading..." } } diff --git a/frontend/public/locales/en/contact.json b/frontend/public/locales/en/contact.json new file mode 100644 index 0000000..e69de29 diff --git a/frontend/public/locales/en/registration.json b/frontend/public/locales/en/registration.json new file mode 100644 index 0000000..e69de29 diff --git a/frontend/public/locales/en/support.json b/frontend/public/locales/en/support.json new file mode 100644 index 0000000..e69de29 diff --git a/frontend/public/locales/en/tournaments.json b/frontend/public/locales/en/tournaments.json new file mode 100644 index 0000000..e69de29 diff --git a/frontend/public/locales/uk/about.json b/frontend/public/locales/uk/about.json new file mode 100644 index 0000000..7412307 --- /dev/null +++ b/frontend/public/locales/uk/about.json @@ -0,0 +1,32 @@ +{ + "hero": { + "bg_text": "ПРО НАС", + "title": "Хто ми", + "description": "Майбутнє, де кожна дитина з малозабезпечених сімей в Україні матиме рівний доступ до можливостей, що змінюють життя, через ІТ-освіту, емоційну стійкість та творчий розвиток." + }, + "history": { + "badge": "Наша Історія", + "title": "Від сміливого бачення до реальних дій", + "quote": "У 2022 році, серед викликів та негараздів, з якими зіткнулася Україна, народилася ініціатива – Star for Life Ukraine.", + "p1": "Те, що починалося як бачення, швидко перетворилося на дії, і до початку 2023 року ми перетворилися на повноцінний благодійний фонд. Наше коріння сягає світового бренду «Star for Life», що був заснований у 2005 році з метою розширення можливостей дітей у всьому світі. Маючи представництва на різних континентах, ініціатива позитивно вплинула на життя понад 500 000 дітей.", + "p2": "Наша подорож в Україні розпочалася з нагальної потреби підтримати молодих людей, які постраждали від постійних викликів у регіоні. Оскільки понад 5 мільйонів дітей потребують підтримки, наша місія була чіткою: забезпечити їх інструментами, освітою та можливостями, на які вони заслуговують." + }, + "what_we_do": { + "title": "Що Ми Робимо", + "description": "Тисячі дітей з малозабезпечених сімей в Україні не мають доступу до якісної освіти. «Star for Life Ukraine» працює над тим, щоб змінити це, забезпечуючи кожній дитині безпечний розвиток.", + "pillars": { + "educational": { + "title": "Освітня", + "description": "Розширюємо знання та навички для наступного покоління." + }, + "mental": { + "title": "Ментальна", + "description": "Виховуємо стійкість, впевненість та психологічне благополуччя." + }, + "humanitarian": { + "title": "Гуманітарна", + "description": "Підтримуємо дітей України у найскрутніші часи та надаємо захист." + } + } + } +} diff --git a/frontend/public/locales/uk/auth.json b/frontend/public/locales/uk/auth.json new file mode 100644 index 0000000..8aed07e --- /dev/null +++ b/frontend/public/locales/uk/auth.json @@ -0,0 +1,46 @@ +{ + "slogan": "Твоя історія починається тут. Створюй, втілюй, змінюй світ.", + "title_login": "З поверненням!", + "title_register": "Створити акаунт", + "subtitle_login": "Продовжуйте свій шлях в UGalaxy", + "subtitle_register": "Готовий до нових челенджів?", + "tabs": { + "login": "Вхід", + "register": "Реєстрація" + }, + "fields": { + "nickname": { + "label": "Нікнейм", + "placeholder": "Наприклад, izachoc" + }, + "email": { + "label": "Email", + "placeholder": "name@example.com" + }, + "password": { + "label": "Пароль", + "placeholder": "Мінімум 8 символів", + "forgot": "Забули пароль?" + } + }, + "submit": { + "login": "Увійти", + "register": "Зареєструватись", + "google": "Вхід через Google", + "or": "або" + }, + "footer": { + "agree": "Реєструючись ти погоджуєшся з", + "and": "та", + "terms": "Умовами використання", + "privacy": "Політикою конфіденційності" + }, + "errors": { + "email_invalid": "Некоректний формат email", + "pass_short": "Мінімум 8 символів", + "nick_required": "Нікнейм обов'язковий (мінімум 2 символи)", + "email_in_use": "Цей email вже використовується.", + "invalid_creds": "Невірний email або пароль.", + "unknown": "Сталася помилка. Спробуйте ще раз." + } +} diff --git a/frontend/public/locales/uk/common.json b/frontend/public/locales/uk/common.json index 9f1e5d1..1a737bf 100644 --- a/frontend/public/locales/uk/common.json +++ b/frontend/public/locales/uk/common.json @@ -7,5 +7,39 @@ }, "auth": { "login": "Увійти" + }, + "footer": { + "description": "Місце, де народжуються найкращі ідеї. Твори, навчайся, перемагай.", + "platform_title": "Платформа", + "info_title": "Інформація", + "all_tournaments": "Всі турніри", + "rules": "Правила", + "faq": "FAQ", + "about": "Про нас", + "contact": "Контакти", + "copyright": "© 2026 UGalaxy x Star for Life. Всі права захищено." + }, + "profile_dropdown": { + "button": "Профіль", + "my_profile": "Мій профіль", + "logout": "Вийти" + }, + "notifications": { + "title": "Сповіщення", + "new_count_one": "{{count}} нове", + "new_count_few": "{{count}} нових", + "new_count_many": "{{count}} нових", + "empty": "Немає нових сповіщень" + }, + "errors": { + "404": { + "title": "Ви вийшли за межі системи 👾", + "description": "Сторінку, яку ви шукаєте, було видалено, або вона існує лише в паралельному всесвіті. Давайте повернемося на безпечну територію.", + "back": "Назад", + "go_home": "На головну" + } + }, + "ui": { + "loading": "Завантаження..." } } diff --git a/frontend/public/locales/uk/contact.json b/frontend/public/locales/uk/contact.json new file mode 100644 index 0000000..a6786fc --- /dev/null +++ b/frontend/public/locales/uk/contact.json @@ -0,0 +1,19 @@ +{ + "hero": { + "bg_text": "КОНТАКТИ", + "title": "Залишайся на зв'язку", + "description": "Маєш питання, ідеї або просто хочеш привітатись? Ми завжди відкриті до спілкування. Обирай зручний спосіб!" + }, + "org": { + "badge": "Організація", + "title": "Наші дані", + "email_label": "Електронна пошта", + "id_label": "ЄДРПОУ / Реєстрація", + "date_label": "Дата заснування" + }, + "social": { + "badge": "Ком'юніті", + "title": "Ми в соцмережах", + "description": "Підписуйся, щоб не пропустити нові турніри, челенджі та корисний контент для твого розвитку." + } +} diff --git a/frontend/public/locales/uk/registration.json b/frontend/public/locales/uk/registration.json new file mode 100644 index 0000000..d725688 --- /dev/null +++ b/frontend/public/locales/uk/registration.json @@ -0,0 +1,62 @@ +{ + "title": "Реєстрація на турнір", + "badge_open": "Реєстрація відкрита", + "steps": { + "general": "Загальне", + "members": "Учасники", + "confirm": "Підтвердження" + }, + "step1": { + "title": "Ваша команда", + "tournament": "Турнір", + "format_label": "Формат участі", + "formats": { + "team": "Команда", + "solo": "Соло" + }, + "team_name_label": "Назва команди", + "team_name_placeholder": "Наприклад, Космічні коти", + "captain_label": "Капітан", + "captain_notice": "Дані імпортовано з Google-акаунту. Ви можете оновити їх у налаштуваннях.", + "full_name_label": "Повне ім'я (ПІБ)", + "full_name_opt": "(якщо відрізняється)", + "additional_info": "Додаткова інформація", + "org_label": "Навчальний заклад / Організація", + "org_placeholder": "ЧПФК, КНУ...", + "next": "Далі — Учасники" + }, + "step2": { + "title": "Склад команди", + "subtitle": "Додайте від {{min}} до {{max}} учасників.", + "solo_notice": "Ви берете участь самостійно. Учасники не потрібні.", + "members_label": "Учасники", + "empty_state": "Тут поки порожньо. Додайте першого тіммейта.", + "add_member": "Додати учасника", + "next": "Переглянути заявку" + }, + "step3": { + "title": "Підтвердження", + "subtitle": "Перевірте все перед відправкою.", + "warning": "Після надсилання заявки редагування складу неможливе.", + "submit": "Зареєструватись" + }, + "success": { + "title": "Команду зареєстровано!", + "subtitle": "Ваша заявка успішно надіслана. Очікуйте підтвердження.", + "back_home": "На головну" + }, + "ticket": { + "event": "Подія", + "format": "Формат", + "team": "Команда", + "composition": "Склад", + "captain": "Капітан", + "member": "Учасник", + "empty_team": "Не заповнено", + "stats": { + "members": "Учасників", + "tournament_no": "Турнір №", + "days_left": "Дні до старту" + } + } +} diff --git a/frontend/public/locales/uk/support.json b/frontend/public/locales/uk/support.json new file mode 100644 index 0000000..fe9317c --- /dev/null +++ b/frontend/public/locales/uk/support.json @@ -0,0 +1,28 @@ +{ + "hero": { + "bg_text": "ПІДТРИМКА", + "title": "Долучайся до змін", + "description": "Оберіть зручний для вас спосіб підтримати майбутнє дітей України. Кожна дія має значення." + }, + "options": { + "donate": { + "badge": "Фінансова допомога", + "title": "Задонатити", + "description": "Твій внесок допомагає забезпечувати дітей якісною IT-освітою та психологічною підтримкою.", + "button": "Зробити внесок" + }, + "partnership": { + "badge": "Для бізнесу", + "title": "Партнерство", + "description": "Стань партнером фонду! Разом ми реалізуємо масштабні проєкти та створимо сталі зміни.", + "button": "Стати партнером" + }, + "volunteer": { + "badge": "Твій час", + "title": "Волонтерство", + "description": "Поділися своїми знаннями та навичками. Твоя участь може змінити життя дитини.", + "button": "Стати волонтером" + } + }, + "footer_note": "Кожна гривня, кожна година вашого часу та кожна спільна ініціатива наближають нас до мети. Дякуємо, що ви з нами! 💙💛" +} diff --git a/frontend/public/locales/uk/tournaments.json b/frontend/public/locales/uk/tournaments.json new file mode 100644 index 0000000..df4f1e3 --- /dev/null +++ b/frontend/public/locales/uk/tournaments.json @@ -0,0 +1,33 @@ +{ + "hero": { + "bgText": "ТУРНІРИ", + "title": "Усі турніри", + "description": "Знайди івент, який підходить саме тобі. Від коду до дизайну — збирай команду та перемагай." + }, + "search": { + "placeholder": "Пошук турніру…" + }, + "filters": { + "all": "Всі події", + "registration": "Реєстрація", + "active": "В процесі", + "completed": "Архів" + }, + "results": { + "found": "Знайдено:", + "tournaments_zero": "турнірів", + "tournaments_one": "турнір", + "tournaments_few": "турніри", + "tournaments_many": "турнірів" + }, + "empty": { + "title": "Нічого не знайдено", + "subtitle": "Спробуй змінити фільтр або запит" + }, + "pagination": { + "prev": "Назад", + "next": "Вперед", + "page": "Сторінка", + "of": "з" + } +} diff --git a/frontend/src/i18n/config.ts b/frontend/src/i18n/config.ts index c499c96..616f391 100644 --- a/frontend/src/i18n/config.ts +++ b/frontend/src/i18n/config.ts @@ -10,6 +10,9 @@ i18n .init({ fallbackLng: "uk", supportedLngs: ["uk", "en"], + ns: ["common", "auth"], + defaultNS: "common", + debug: import.meta.env.DEV, interpolation: { From b34687aeb7f13d0702598659717e15dce1cc8382 Mon Sep 17 00:00:00 2001 From: izachoc Date: Wed, 22 Apr 2026 19:45:51 +0300 Subject: [PATCH 05/23] chore: implement adaptive design system, Tailwind variables and core UI components --- frontend/src/App.tsx | 19 ++- frontend/src/components/ui/BrandingPanel.tsx | 59 +++++++ frontend/src/components/ui/Button.tsx | 25 +-- .../src/components/ui/LanguageSwitcher.tsx | 33 ++-- frontend/src/components/ui/Ticket3D.tsx | 148 +++++++++++++++++ frontend/src/components/ui/index.ts | 2 + frontend/src/hooks/useClickOutside.ts | 23 +++ frontend/src/index.css | 153 +++++++++++------- frontend/src/utils/cn.ts | 6 + 9 files changed, 383 insertions(+), 85 deletions(-) create mode 100644 frontend/src/components/ui/BrandingPanel.tsx create mode 100644 frontend/src/components/ui/Ticket3D.tsx create mode 100644 frontend/src/hooks/useClickOutside.ts create mode 100644 frontend/src/utils/cn.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ee5fe60..35416f8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,7 @@ import { SupportPage } from "./pages/SupportPage/SupportPage"; import { FaqPage } from "./pages/FaqPage/FaqPage"; import { RulesPage } from "./pages/Rules/Rules"; import ProtectedRoute from "./components/ProtectedRoute/ProtectedRoute"; -import ForgotPassword from "./pages/Auth/ForgotPassword"; +import { ForgotPassword } from "./pages/Auth/ForgotPassword"; import { RoleRequestPage } from "./pages/GetRole/RoleRequestPage"; import { Toaster, toast } from "react-hot-toast"; import { AuthPage } from "./pages/Auth/AuthPage"; @@ -22,6 +22,9 @@ import { auth } from "./firebase"; import { onAuthStateChanged } from "firebase/auth"; import { useEffect, useState } from "react"; +// Імпорт нашої нової сторінки +import { RegPage } from "./pages/RegistrationPage/RegCommand"; + // Утиліта для скролу нагору при кожній зміні URL const ScrollToTop = () => { const { pathname } = useLocation(); @@ -74,7 +77,7 @@ export const App = () => { <> - {/* <--- Додали сюди! */} + {/* Сторінки з Хедером та Футером */} }> @@ -104,11 +107,23 @@ export const App = () => { } /> } /> + + {/* Самостійні сторінки без MainLayout (на весь екран) */} } /> } /> } /> + + {/* СТОРІНКА РЕЄСТРАЦІЇ НА ТУРНІР */} + + + + } + /> diff --git a/frontend/src/components/ui/BrandingPanel.tsx b/frontend/src/components/ui/BrandingPanel.tsx new file mode 100644 index 0000000..8437af5 --- /dev/null +++ b/frontend/src/components/ui/BrandingPanel.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { cn } from "../../utils/cn"; + +interface BrandingPanelProps { + position?: "left" | "right"; + width?: "half" | "44%"; + children?: React.ReactNode; +} + +export const BrandingPanel: React.FC = ({ + position = "left", + width = "half", + children, +}) => { + const isRight = position === "right"; + + return ( +
+
+
+ UGALAXY ★ STAR FOR LIFE ★ UGALAXY ★ STAR FOR LIFE ★  + UGALAXY ★ STAR FOR LIFE ★ UGALAXY ★ STAR FOR LIFE ★  +
+
+ +
+
+ STAR FOR LIFE ★ UGALAXY ★ STAR FOR LIFE ★ UGALAXY ★  + STAR FOR LIFE ★ UGALAXY ★ STAR FOR LIFE ★ UGALAXY ★  +
+
+
{children}
+ +
+ + + +
+
+ ); +}; diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index f3e1872..8921512 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -1,6 +1,7 @@ import { type ReactNode } from "react"; import { motion, type HTMLMotionProps } from "framer-motion"; import { Loader2 } from "lucide-react"; +import { cn } from "../../utils/cn"; type ButtonVariant = "primary" | "accent" | "outline" | "ghost"; type ButtonSize = "sm" | "md" | "lg"; @@ -13,15 +14,16 @@ interface ButtonProps extends Omit, "ref"> { rightIcon?: ReactNode; } +// Повністю переписано на дизайн-змінні const variantStyles: Record = { primary: - "bg-indigo-500 text-white shadow-[0_12px_28px_rgba(99,102,241,0.35)] hover:bg-indigo-600 hover:shadow-[0_16px_32px_rgba(99,102,241,0.4)] border-2 border-transparent", + "bg-primary text-white shadow-lg shadow-primary/30 hover:bg-primary/90 hover:shadow-xl hover:shadow-primary/40 border-2 border-transparent", accent: - "bg-amber-400 text-slate-900 shadow-[0_12px_28px_rgba(251,191,36,0.3)] hover:bg-amber-500 hover:shadow-[0_16px_32px_rgba(251,191,36,0.4)] border-2 border-transparent", + "bg-accent text-slate-900 shadow-lg shadow-accent/30 hover:bg-accent/90 hover:shadow-xl hover:shadow-accent/40 border-2 border-transparent", // Жовта кнопка завжди має чорний текст для контрасту outline: - "bg-transparent border-2 border-slate-200 text-slate-900 hover:border-indigo-200 hover:shadow-[0_8px_24px_rgba(99,102,241,0.15)]", + "bg-transparent border-2 border-border text-text-main hover:border-primary hover:text-primary hover:shadow-lg hover:shadow-primary/15", ghost: - "bg-transparent border-2 border-transparent text-slate-500 hover:text-slate-900 hover:bg-slate-100", + "bg-transparent border-2 border-transparent text-text-muted hover:text-text-main hover:bg-border/50", }; const sizeStyles: Record = { @@ -37,7 +39,7 @@ export const Button = ({ leftIcon, rightIcon, children, - className = "", + className, disabled, ...props }: ButtonProps) => { @@ -49,13 +51,12 @@ export const Button = ({ whileTap={{ scale: isDisabled ? 1 : 0.98 }} transition={{ duration: 0.2 }} disabled={isDisabled} - className={` - relative flex items-center justify-center gap-2.5 font-quicksand font-bold rounded-full transition-colors duration-200 - disabled:opacity-70 disabled:pointer-events-none outline-none focus-visible:ring-4 focus-visible:ring-indigo-500/20 - ${variantStyles[variant]} - ${sizeStyles[size]} - ${className} - `} + className={cn( + "relative flex items-center justify-center gap-2.5 font-quicksand font-bold rounded-full transition-all duration-300 outline-none focus-visible:ring-4 focus-visible:ring-primary/20 disabled:opacity-70 disabled:pointer-events-none", + variantStyles[variant], + sizeStyles[size], + className, + )} {...props} > {isLoading && } diff --git a/frontend/src/components/ui/LanguageSwitcher.tsx b/frontend/src/components/ui/LanguageSwitcher.tsx index 4f47f52..1d59d95 100644 --- a/frontend/src/components/ui/LanguageSwitcher.tsx +++ b/frontend/src/components/ui/LanguageSwitcher.tsx @@ -1,22 +1,23 @@ -// LanguageSwitcher.tsx import { useTranslation } from "react-i18next"; import { motion } from "framer-motion"; +import { cn } from "../../utils/cn"; + +const LANGUAGES = [ + { code: "uk", label: "UK", flag: "🇺🇦", title: "Українська" }, + { code: "en", label: "EN", flag: "🇬🇧", title: "English" }, +]; export const LanguageSwitcher = () => { const { i18n } = useTranslation(); - const languages = [ - { code: "uk", label: "UK", flag: "🇺🇦", title: "Українська" }, - { code: "en", label: "EN", flag: "🇬🇧", title: "English" }, - ]; - return (
- {languages.map((lang) => { + {LANGUAGES.map((lang) => { const isActive = i18n.resolvedLanguage === lang.code; return ( ); diff --git a/frontend/src/components/ui/Ticket3D.tsx b/frontend/src/components/ui/Ticket3D.tsx new file mode 100644 index 0000000..3c3d4cd --- /dev/null +++ b/frontend/src/components/ui/Ticket3D.tsx @@ -0,0 +1,148 @@ +import React, { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { motion, useMotionValue, useTransform, useSpring } from "framer-motion"; +import { cn } from "../../utils/cn"; + +interface TicketProps { + eventTitle: string; + format: string; + teamName: string; + captainName: string; + members: { firstName: string }[]; +} + +export const Ticket3D: React.FC = ({ + eventTitle, + format, + teamName, + captainName, + members, +}) => { + const { t } = useTranslation("registration"); + const ref = useRef(null); + const [isHovered, setIsHovered] = useState(false); + + const mouseX = useMotionValue(0.5); + const mouseY = useMotionValue(0.5); + + const springConfig = { damping: 25, stiffness: 300 }; + const smoothMouseX = useSpring(mouseX, springConfig); + const smoothMouseY = useSpring(mouseY, springConfig); + + const rotateX = useTransform(smoothMouseY, [0, 1], [10, -10]); + const rotateY = useTransform(smoothMouseX, [0, 1], [-12, 12]); + + const handleMouseMove = (e: React.MouseEvent) => { + if (!ref.current) return; + const rect = ref.current.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + const y = (e.clientY - rect.top) / rect.height; + mouseX.set(x); + mouseY.set(y); + }; + + const handleMouseLeave = () => { + setIsHovered(false); + mouseX.set(0.5); + mouseY.set(0.5); + }; + + return ( +
setIsHovered(true)} + className="relative z-10 w-[88%] max-w-[390px] [perspective:900px]" + > + +
+
+
+
+ {t("ticket.event")} +
+
+ {eventTitle} +
+
+
+
+ {t("ticket.format")} +
+
+ {format === "team" + ? t("step1.formats.team") + : t("step1.formats.solo")} +
+
+
+
+ {t("ticket.team")} +
+
+ {teamName || t("ticket.empty_team")} +
+
+ +
+ {/* bg-bg-body для дірок робить ефект "прозорості" */} +
+
+
+ +
+
+ {t("ticket.composition")} +
+
+
+ {/* Прозорі фони для беджів */} + + {t("ticket.captain")} + + + {captainName} + +
+ {members.map((m, i) => ( +
+ + {t("ticket.member")} + + + {m.firstName || "..."} + +
+ ))} +
+
+ +
+
+
+ UGalaxy +
+
+ +
+ ); +}; diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index c5676d3..d950c22 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -1,2 +1,4 @@ export * from "./Button"; +export * from "./BrandingPanel"; +export * from "./Ticket3D"; // export * from "./Card"; diff --git a/frontend/src/hooks/useClickOutside.ts b/frontend/src/hooks/useClickOutside.ts new file mode 100644 index 0000000..965b7a7 --- /dev/null +++ b/frontend/src/hooks/useClickOutside.ts @@ -0,0 +1,23 @@ +import { useEffect, type RefObject } from "react"; + +export const useClickOutside = ( + ref: RefObject, + handler: (event: MouseEvent | TouchEvent) => void, +) => { + useEffect(() => { + const listener = (event: MouseEvent | TouchEvent) => { + if (!ref.current || ref.current.contains(event.target as Node)) { + return; + } + handler(event); + }; + + document.addEventListener("mousedown", listener); + document.addEventListener("touchstart", listener); + + return () => { + document.removeEventListener("mousedown", listener); + document.removeEventListener("touchstart", listener); + }; + }, [ref, handler]); +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index ede06c9..9bfee21 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,17 +1,100 @@ @import "tailwindcss"; @import "@firebase-oss/ui-styles/tailwind"; +@layer base { + /* ☀️ СВІТЛА ТЕМА (за замовчуванням) */ + :root { + /* Акцентні кольори (Кнопки, іконки, беджі) */ + --color-primary: #6366f1; /* Indigo 500 */ + --color-accent: #fbbf24; /* Amber 400 */ + --color-pink-accent: #ec4899; /* Pink 500 */ + + /* Масивні блоки (Hero, Банери, Хідер) */ + --color-hero-from: #6b73ff; /* Яскравий фіолетово-синій старт */ + --color-hero-to: #4c51bf; /* Темніший фіолетовий фініш */ + --color-header-bg: rgba( + 107, + 115, + 255, + 0.95 + ); /* Напівпрозорий колір хідера при скролі */ + + /* Фони сторінки та карток */ + --color-bg-body: #f8fafc; /* Slate 50 - загальний фон */ + --color-bg-card: #ffffff; /* Білі картки */ + --color-dark-theme: #0f172a; /* Футер та найтемніші блоки */ + + /* Тексти та бордери */ + --color-text-main: #0f172a; /* Slate 900 - заголовки */ + --color-text-muted: #64748b; /* Slate 500 - звичайний текст */ + --color-border: #f1f5f9; /* Slate 100 - легкі рамки карток */ + } + + /* 🌙 ТЕМНА ТЕМА */ + .dark { + /* Акценти залишаються яскравими для "неонового" контрасту */ + --color-primary: #818cf8; /* Indigo 400 */ + --color-accent: #fbbf24; /* Amber 400 */ + --color-pink-accent: #f472b6; /* Pink 400 */ + + /* Масивні блоки (Hero, Банери) зливаються з глибоким нічним фоном */ + --color-hero-from: #1e1b4b; /* Deep Indigo 950 - дає легкий магічний відтінок */ + --color-hero-to: #0f172a; /* Slate 900 - ідеально розчиняється у фоні сторінки */ + --color-header-bg: rgba( + 15, + 23, + 42, + 0.95 + ); /* Slate 900 з прозорістю для блюру */ + + /* Фони (Elevation) */ + --color-bg-body: #0f172a; /* Slate 900 - глибокий темний фон */ + --color-bg-card: #1e293b; /* Slate 800 - картки трохи ближче до користувача */ + --color-dark-theme: #020617; /* Slate 950 - футер/найтемніші блоки */ + + /* Тексти та бордери інвертуються */ + --color-text-main: #f8fafc; /* Slate 50 - м'який білий для тексту */ + --color-text-muted: #94a3b8; /* Slate 400 - приглушений текст */ + --color-border: #334155; /* Slate 700 - ледь помітні рамки */ + } + + body { + /* Боді автоматично братиме колір фону і тексту залежно від теми + плавний перехід */ + @apply font-inter bg-bg-body text-text-main overflow-x-hidden leading-[1.5] transition-colors duration-500; + } + + h1, + h2, + h3, + h4 { + @apply font-quicksand font-extrabold text-text-main transition-colors duration-500; + } +} + @theme { --font-inter: "Inter", sans-serif; --font-quicksand: "Quicksand", sans-serif; - --color-primary: #6366f1; - --color-accent: #fbbf24; - --color-bg-body: #f8fafc; - --color-bg-card: #ffffff; - --color-dark-theme: #1e293b; - --color-pink-accent: #ec4899; + + /* Реєструємо Акценти */ + --color-primary: var(--color-primary); + --color-accent: var(--color-accent); + --color-pink-accent: var(--color-pink-accent); + + /* Реєструємо Масивні блоки */ + --color-hero-from: var(--color-hero-from); + --color-hero-to: var(--color-hero-to); + --color-header-bg: var(--color-header-bg); + + /* Реєструємо Фони та Тексти */ + --color-bg-body: var(--color-bg-body); + --color-bg-card: var(--color-bg-card); + --color-dark-theme: var(--color-dark-theme); + --color-text-main: var(--color-text-main); + --color-text-muted: var(--color-text-muted); + --color-border: var(--color-border); --animate-marquee: marquee 40s linear infinite; + --animate-marquee-reverse: marquee-reverse 65s linear infinite; @keyframes marquee { 0% { @@ -21,17 +104,13 @@ transform: translateX(-50%); } } -} - -@layer base { - body { - @apply font-inter bg-primary text-white overflow-x-hidden leading-[1.5]; - } - h1, - h2, - h3, - h4 { - @apply font-quicksand font-extrabold; + @keyframes marquee-reverse { + 0% { + transform: translateX(-50%); + } + 100% { + transform: translateX(0); + } } } @@ -44,43 +123,3 @@ display: none; } } - -@layer components { - .btn { - @apply inline-flex items-center justify-center px-9 py-4 rounded-full font-quicksand font-bold text-[18px] cursor-pointer no-underline border-none transition-all duration-200; - } - .btn-accent { - @apply bg-accent text-slate-900 hover:-translate-y-1 hover:scale-105 shadow-[0_10px_20px_rgba(251,191,36,0.4)]; - } - .btn-primary { - @apply bg-primary text-white hover:-translate-y-1 hover:scale-105 shadow-[0_10px_20px_rgba(99,102,241,0.4)]; - } - .btn-dark { - @apply bg-dark-theme text-white hover:-translate-y-1 hover:scale-105; - } - .btn-outline { - @apply bg-transparent text-white border-2 border-white/40 hover:bg-white/10; - } - - .tournaments { - @apply bg-bg-body text-dark-theme relative py-[100px] pt-[160px]; - } - .section-header { - @apply px-[5%] mb-[60px] flex justify-between items-end; - } - .section-header h2 { - @apply text-[64px] leading-[1] uppercase; - } - .slider-area { - @apply relative w-full overflow-hidden pb-5; - } - .cards-track { - @apply flex gap-8 px-[5%] py-5 w-max cursor-grab active:cursor-grabbing; - } - .custom-scrollbar { - @apply mx-[5%] mt-10 h-3 bg-slate-200 rounded-full relative; - } - .scroll-thumb { - @apply absolute top-0 left-0 h-full w-[150px] bg-primary rounded-full cursor-grab transition-colors active:cursor-grabbing active:bg-indigo-600; - } -} diff --git a/frontend/src/utils/cn.ts b/frontend/src/utils/cn.ts new file mode 100644 index 0000000..a5ef193 --- /dev/null +++ b/frontend/src/utils/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} From 11756988acae48374e128fdad3ff32cea7655aca Mon Sep 17 00:00:00 2001 From: izachoc Date: Wed, 22 Apr 2026 19:45:58 +0300 Subject: [PATCH 06/23] feat: build adaptive Header with glassmorphism, theme toggle and dropdowns --- frontend/src/components/Footer.tsx | 84 +++++---- frontend/src/components/Header.tsx | 174 +++++++++--------- frontend/src/components/MainLayout.tsx | 4 +- .../src/components/NotificationsDropdown.tsx | 82 ++++++--- frontend/src/components/ProfileDropdown.tsx | 51 +++-- frontend/src/components/ScrollToTop.tsx | 2 +- frontend/src/components/ThemeToggle.tsx | 46 +++++ .../src/components/layouts/AuthLayout.tsx | 59 ++++++ 8 files changed, 317 insertions(+), 185 deletions(-) create mode 100644 frontend/src/components/ThemeToggle.tsx create mode 100644 frontend/src/components/layouts/AuthLayout.tsx diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 5f2f3d5..15dd375 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -1,71 +1,79 @@ import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; + +const PLATFORM_LINKS = [ + { to: "/tournaments", key: "footer.all_tournaments" }, + { to: "/rules", key: "footer.rules" }, + { to: "/faq", key: "footer.faq" }, +]; + +const INFO_LINKS = [ + { to: "/about-us", key: "footer.about" }, + { to: "/contact", key: "footer.contact" }, +]; export const Footer = () => { + const { t } = useTranslation("common"); + return ( -
-