From 71f772f2b96e8e573d940d87d3584f64c4d6d665 Mon Sep 17 00:00:00 2001 From: arrelin Date: Fri, 23 Jan 2026 12:23:25 +0300 Subject: [PATCH] init --- frontend/package-lock.json | 108 ++++++++++++- frontend/package.json | 3 + frontend/src/App.tsx | 4 +- frontend/src/components/ConfirmModal.tsx | 12 +- frontend/src/components/ShoppingListModal.tsx | 50 +++--- frontend/src/i18n/index.ts | 26 ++++ frontend/src/i18n/locales/en.json | 143 ++++++++++++++++++ frontend/src/i18n/locales/ru.json | 143 ++++++++++++++++++ frontend/src/main.tsx | 1 + frontend/src/pages/AdminPanel.tsx | 44 +++--- frontend/src/pages/FamilyView.tsx | 93 ++++++------ frontend/src/pages/InvitePage.tsx | 27 ++-- frontend/src/pages/Login.tsx | 10 +- frontend/src/pages/NoFamily.tsx | 36 ++--- 14 files changed, 569 insertions(+), 131 deletions(-) create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/locales/en.json create mode 100644 frontend/src/i18n/locales/ru.json diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 94e1ce8..4148a64 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,9 +10,12 @@ "dependencies": { "@tailwindcss/postcss": "^4.1.18", "axios": "^1.13.2", + "i18next": "^25.8.0", + "i18next-browser-languagedetector": "^8.2.0", "lucide-react": "^0.561.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-i18next": "^16.5.3", "react-router-dom": "^7.10.1", "zustand": "^5.0.9" }, @@ -280,6 +283,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -2988,6 +3000,55 @@ "hermes-estree": "0.25.1" } }, + "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/i18next": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.0.tgz", + "integrity": "sha512-urrg4HMFFMQZ2bbKRK7IZ8/CTE7D8H4JRlAwqA2ZwDRFfdd0K/4cdbNNLgfn9mo+I/h9wJu61qJzH7jCFAhUZQ==", + "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.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz", + "integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3731,6 +3792,33 @@ "react": "^19.2.1" } }, + "node_modules/react-i18next": { + "version": "16.5.3", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.3.tgz", + "integrity": "sha512-fo+/NNch37zqxOzlBYrWMx0uy/yInPkRfjSuy4lqKdaecR17nvCHnEUt3QyzA8XjQ2B/0iW/5BhaHR3ZmukpGw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.6.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -3977,7 +4065,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4059,6 +4147,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.2.7", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz", @@ -4134,6 +4231,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/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 80ee3b6..7d0a8f6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,9 +12,12 @@ "dependencies": { "@tailwindcss/postcss": "^4.1.18", "axios": "^1.13.2", + "i18next": "^25.8.0", + "i18next-browser-languagedetector": "^8.2.0", "lucide-react": "^0.561.0", "react": "^19.2.0", "react-dom": "^19.2.0", + "react-i18next": "^16.5.3", "react-router-dom": "^7.10.1", "zustand": "^5.0.9" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b1d8c3d..9f4a031 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import Login from './pages/Login'; import FamilyView from './pages/FamilyView'; import AdminPanel from './pages/AdminPanel'; @@ -10,6 +11,7 @@ import { authApi } from './api/client'; import { Loader2 } from 'lucide-react'; function AppContent() { + const { t } = useTranslation(); const { user, isAuthenticated, isLoading, setUser, setIsLoading } = useStore(); const location = useLocation(); @@ -41,7 +43,7 @@ function AppContent() {
- Загрузка... + {t('common.loading')}
); diff --git a/frontend/src/components/ConfirmModal.tsx b/frontend/src/components/ConfirmModal.tsx index eebded0..2263db5 100644 --- a/frontend/src/components/ConfirmModal.tsx +++ b/frontend/src/components/ConfirmModal.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { X, AlertTriangle } from 'lucide-react'; interface ConfirmModalProps { @@ -13,12 +14,15 @@ interface ConfirmModalProps { export default function ConfirmModal({ title, message, - confirmText = 'Подтвердить', - cancelText = 'Отмена', + confirmText, + cancelText, onConfirm, onCancel, variant = 'danger', }: ConfirmModalProps) { + const { t } = useTranslation(); + const defaultConfirmText = confirmText || t('common.confirm'); + const defaultCancelText = cancelText || t('common.cancel'); const getVariantStyles = () => { switch (variant) { case 'danger': @@ -67,13 +71,13 @@ export default function ConfirmModal({ onClick={onCancel} className="flex-1 px-6 py-3 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-2xl transition-all font-semibold" > - {cancelText} + {defaultCancelText} diff --git a/frontend/src/components/ShoppingListModal.tsx b/frontend/src/components/ShoppingListModal.tsx index fe97873..29f7a73 100644 --- a/frontend/src/components/ShoppingListModal.tsx +++ b/frontend/src/components/ShoppingListModal.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { shoppingItemApi } from '../api/client'; import type { ShoppingItem } from '../types'; import { @@ -23,6 +24,7 @@ type ConfirmAction = | { type: 'clear-all' }; export default function ShoppingListModal({ familyId, onClose }: ShoppingListModalProps) { + const { t } = useTranslation(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); const [newItemName, setNewItemName] = useState(''); @@ -41,7 +43,7 @@ export default function ShoppingListModal({ familyId, onClose }: ShoppingListMod setItems(response.data); } catch (err) { console.error('Error loading shopping items:', err); - alert('Ошибка загрузки списка покупок'); + alert(t('shopping.loadError')); } finally { setLoading(false); } @@ -56,7 +58,7 @@ export default function ShoppingListModal({ familyId, onClose }: ShoppingListMod loadItems(); } catch (err) { console.error('Error adding item:', err); - alert('Ошибка добавления покупки'); + alert(t('shopping.addError')); } }; @@ -66,7 +68,7 @@ export default function ShoppingListModal({ familyId, onClose }: ShoppingListMod loadItems(); } catch (err) { console.error('Error toggling purchased status:', err); - alert('Ошибка изменения статуса'); + alert(t('shopping.toggleError')); } }; @@ -80,7 +82,7 @@ export default function ShoppingListModal({ familyId, onClose }: ShoppingListMod loadItems(); } catch (err) { console.error('Error deleting item:', err); - alert('Ошибка удаления покупки'); + alert(t('shopping.deleteError')); } }; @@ -99,7 +101,7 @@ export default function ShoppingListModal({ familyId, onClose }: ShoppingListMod loadItems(); } catch (err) { console.error('Error updating item:', err); - alert('Ошибка обновления покупки'); + alert(t('shopping.updateError')); } }; @@ -118,7 +120,7 @@ export default function ShoppingListModal({ familyId, onClose }: ShoppingListMod loadItems(); } catch (err) { console.error('Error marking all as purchased:', err); - alert('Ошибка обновления списка'); + alert(t('shopping.markAllError')); } }; @@ -132,7 +134,7 @@ export default function ShoppingListModal({ familyId, onClose }: ShoppingListMod loadItems(); } catch (err) { console.error('Error clearing all items:', err); - alert('Ошибка очистки списка'); + alert(t('shopping.clearError')); } }; @@ -160,22 +162,22 @@ export default function ShoppingListModal({ familyId, onClose }: ShoppingListMod switch (confirmAction.type) { case 'delete-item': return { - title: 'Удалить покупку?', - message: 'Покупка будет удалена из списка безвозвратно.', - confirmText: 'Удалить', + title: t('confirm.deleteItem'), + message: t('confirm.deleteItemMessage'), + confirmText: t('common.delete'), }; case 'mark-all': return { - title: 'Пометить все как купленные?', - message: 'Все покупки в списке будут отмечены как купленные.', - confirmText: 'Пометить', + title: t('confirm.markAll'), + message: t('confirm.markAllMessage'), + confirmText: t('confirm.markButton'), variant: 'info' as const, }; case 'clear-all': return { - title: 'Очистить список?', - message: 'Все покупки будут удалены из списка безвозвратно.', - confirmText: 'Очистить', + title: t('confirm.clearAll'), + message: t('confirm.clearAllMessage'), + confirmText: t('shopping.clear'), }; } }; @@ -193,7 +195,7 @@ export default function ShoppingListModal({ familyId, onClose }: ShoppingListMod
-

Список покупок

+

{t('shopping.title')}

@@ -232,7 +234,7 @@ export default function ShoppingListModal({ familyId, onClose }: ShoppingListMod
{unpurchasedItems.length > 0 && (
-

К покупке

+

{t('shopping.toBuy')}

{unpurchasedItems.map((item) => (
0 && (
-

Куплено

+

{t('shopping.purchased')}

{purchasedItems.map((item) => (
-

Список покупок пуст

+

{t('shopping.empty')}

)}
@@ -343,14 +345,14 @@ export default function ShoppingListModal({ familyId, onClose }: ShoppingListMod className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-2xl hover:shadow-lg transition-all font-semibold" > - Все куплено + {t('shopping.allPurchased')}
diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts new file mode 100644 index 0000000..5c9e366 --- /dev/null +++ b/frontend/src/i18n/index.ts @@ -0,0 +1,26 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import ru from './locales/ru.json'; +import en from './locales/en.json'; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + ru: { translation: ru }, + en: { translation: en }, + }, + fallbackLng: 'ru', + interpolation: { + escapeValue: false, + }, + detection: { + order: ['navigator', 'htmlTag', 'localStorage'], + caches: ['localStorage'], + }, + }); + +export default i18n; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json new file mode 100644 index 0000000..22a2766 --- /dev/null +++ b/frontend/src/i18n/locales/en.json @@ -0,0 +1,143 @@ +{ + "common": { + "loading": "Loading...", + "error": "Error", + "cancel": "Cancel", + "confirm": "Confirm", + "delete": "Delete", + "add": "Add", + "create": "Create", + "save": "Save", + "back": "Back", + "toHome": "Home", + "logout": "Log out", + "exit": "Exit", + "currency": "$" + }, + "login": { + "title": "Family Budget", + "subtitle": "Sign in to continue", + "googleButton": "Sign in with Google", + "authError": "Error getting authorization link" + }, + "noFamily": { + "welcome": "Welcome!", + "createFamily": "Create your family", + "familyName": "Family name", + "familyNameRequired": "Family name *", + "familyNamePlaceholder": "For example: Smith Family", + "password": "Password", + "passwordOptional": "(optional)", + "passwordPlaceholder": "For access protection", + "passwordHint": "Password will be needed to access the family budget", + "createButton": "Create family", + "adminPanel": "Admin panel", + "enterFamilyName": "Enter family name", + "alreadyInFamily": "You are already in a family", + "createError": "Error creating family", + "joiningFamily": "Joining family...", + "invalidInvite": "Invite link is invalid or expired", + "joinError": "Error joining family" + }, + "family": { + "defaultName": "Family", + "totalLimit": "Total limit", + "totalRemaining": "Total remaining", + "shoppingList": "Shopping list", + "inviteMember": "Invite member", + "loadError": "Error loading categories" + }, + "category": { + "remaining": "Remaining:", + "limit": "Limit:", + "percentRemaining": "% remaining", + "addExpense": "Add expense", + "expense": "Expense", + "reset": "Reset", + "history": "History", + "management": "Category management", + "newCategory": "New category", + "categoryName": "Category name", + "categoryLimit": "Limit ($)", + "addCategory": "Add category", + "deleteConfirm": "Delete category?", + "resetConfirm": "Delete all expenses for this category?", + "createError": "Error creating category", + "deleteError": "Error deleting category", + "resetError": "Error resetting expenses" + }, + "expense": { + "addTitle": "Add expense", + "amount": "Amount ($)", + "amountPlaceholder": "0.00", + "description": "Description", + "descriptionPlaceholder": "Optional", + "historyTitle": "Expense history", + "noExpenses": "No expenses", + "addError": "Error adding expense", + "historyError": "Error loading expense history" + }, + "invite": { + "title": "Invite member", + "description": "Create an invite link to add a new member to the family. The link will be valid for 7 days.", + "createLink": "Create link", + "creating": "Creating...", + "sendLink": "Send this link to the person you want to invite:", + "copyLink": "Copy link", + "copied": "Copied!", + "createError": "Error creating invite link", + "pageTitle": "Family invitation", + "pageDescription": "You have been invited to join a family budget. Sign in with Google to accept the invitation.", + "loginAndJoin": "Sign in and join", + "validating": "Validating invitation...", + "joining": "Joining family...", + "invalid": "Link is invalid or has expired", + "notFound": "Link not found", + "alreadyInFamily": "You are already in a family", + "joinError": "Error joining family" + }, + "admin": { + "title": "Admin panel", + "subtitle": "Family management", + "accessDenied": "Access denied", + "requiresAdmin": "Administrator rights required", + "createFamily": "Create new family", + "familyName": "Family name", + "familyNamePlaceholder": "For example: Smith Family", + "familyPassword": "Family password", + "familyPasswordPlaceholder": "Protect your family with a password", + "createButton": "Create family", + "familyList": "Family list", + "noFamilies": "No families found", + "createFirst": "Create the first family", + "fillNameAndPassword": "Fill in the family name and password", + "createError": "Error creating family", + "deleteConfirm": "Delete family?", + "deleteError": "Error deleting family" + }, + "shopping": { + "title": "Shopping list", + "addPlaceholder": "Add item...", + "toBuy": "To buy", + "purchased": "Purchased", + "empty": "Shopping list is empty", + "allPurchased": "All purchased", + "clear": "Clear", + "loadError": "Error loading shopping list", + "addError": "Error adding item", + "toggleError": "Error changing status", + "deleteError": "Error deleting item", + "updateError": "Error updating item", + "markAllError": "Error updating list", + "clearError": "Error clearing list" + }, + "confirm": { + "deleteItem": "Delete item?", + "deleteItemMessage": "The item will be permanently removed from the list.", + "markAll": "Mark all as purchased?", + "markAllMessage": "All items in the list will be marked as purchased.", + "markButton": "Mark", + "clearAll": "Clear list?", + "clearAllMessage": "All items will be permanently removed from the list." + } +} diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json new file mode 100644 index 0000000..8fad513 --- /dev/null +++ b/frontend/src/i18n/locales/ru.json @@ -0,0 +1,143 @@ +{ + "common": { + "loading": "Загрузка...", + "error": "Ошибка", + "cancel": "Отмена", + "confirm": "Подтвердить", + "delete": "Удалить", + "add": "Добавить", + "create": "Создать", + "save": "Сохранить", + "back": "Назад", + "toHome": "На главную", + "logout": "Выйти", + "exit": "Выход", + "currency": "₽" + }, + "login": { + "title": "Семейный бюджет", + "subtitle": "Войдите, чтобы продолжить", + "googleButton": "Войти через Google", + "authError": "Ошибка при получении ссылки для авторизации" + }, + "noFamily": { + "welcome": "Добро пожаловать!", + "createFamily": "Создайте свою семью", + "familyName": "Название семьи", + "familyNameRequired": "Название семьи *", + "familyNamePlaceholder": "Например: Семья Ивановых", + "password": "Пароль", + "passwordOptional": "(необязательно)", + "passwordPlaceholder": "Для защиты доступа", + "passwordHint": "Пароль понадобится для доступа к бюджету семьи", + "createButton": "Создать семью", + "adminPanel": "Админ панель", + "enterFamilyName": "Введите название семьи", + "alreadyInFamily": "Вы уже состоите в семье", + "createError": "Ошибка при создании семьи", + "joiningFamily": "Присоединение к семье...", + "invalidInvite": "Ссылка-приглашение недействительна или истекла", + "joinError": "Ошибка при присоединении к семье" + }, + "family": { + "defaultName": "Семья", + "totalLimit": "Общий лимит", + "totalRemaining": "Общий остаток", + "shoppingList": "Список покупок", + "inviteMember": "Пригласить участника", + "loadError": "Ошибка загрузки категорий" + }, + "category": { + "remaining": "Остаток:", + "limit": "Лимит:", + "percentRemaining": "% осталось", + "addExpense": "Добавить расход", + "expense": "Расход", + "reset": "Обнулить", + "history": "История", + "management": "Управление категориями", + "newCategory": "Новая категория", + "categoryName": "Название категории", + "categoryLimit": "Лимит (₽)", + "addCategory": "Добавить категорию", + "deleteConfirm": "Удалить категорию?", + "resetConfirm": "Удалить все траты по этой категории?", + "createError": "Ошибка создания категории", + "deleteError": "Ошибка удаления категории", + "resetError": "Ошибка сброса трат" + }, + "expense": { + "addTitle": "Добавить расход", + "amount": "Сумма (₽)", + "amountPlaceholder": "0.00", + "description": "Описание", + "descriptionPlaceholder": "Опционально", + "historyTitle": "История трат", + "noExpenses": "Нет трат", + "addError": "Ошибка добавления расхода", + "historyError": "Ошибка загрузки истории трат" + }, + "invite": { + "title": "Пригласить участника", + "description": "Создайте ссылку-приглашение, чтобы добавить нового участника в семью. Ссылка будет действительна 7 дней.", + "createLink": "Создать ссылку", + "creating": "Создание...", + "sendLink": "Отправьте эту ссылку участнику, которого хотите пригласить:", + "copyLink": "Скопировать ссылку", + "copied": "Скопировано!", + "createError": "Ошибка создания ссылки-приглашения", + "pageTitle": "Приглашение в семью", + "pageDescription": "Вас пригласили присоединиться к семейному бюджету. Войдите через Google, чтобы принять приглашение.", + "loginAndJoin": "Войти и присоединиться", + "validating": "Проверка приглашения...", + "joining": "Присоединение к семье...", + "invalid": "Ссылка недействительна или срок её действия истёк", + "notFound": "Ссылка не найдена", + "alreadyInFamily": "Вы уже состоите в семье", + "joinError": "Ошибка при присоединении к семье" + }, + "admin": { + "title": "Админ панель", + "subtitle": "Управление семьями", + "accessDenied": "Доступ запрещен", + "requiresAdmin": "Требуются права администратора", + "createFamily": "Создать новую семью", + "familyName": "Название семьи", + "familyNamePlaceholder": "Например: Семья Ивановых", + "familyPassword": "Пароль семьи", + "familyPasswordPlaceholder": "Защитите семью паролем", + "createButton": "Создать семью", + "familyList": "Список семей", + "noFamilies": "Семьи не найдены", + "createFirst": "Создайте первую семью", + "fillNameAndPassword": "Заполните название и пароль семьи", + "createError": "Ошибка создания семьи", + "deleteConfirm": "Удалить семью?", + "deleteError": "Ошибка удаления семьи" + }, + "shopping": { + "title": "Список покупок", + "addPlaceholder": "Добавить покупку...", + "toBuy": "К покупке", + "purchased": "Куплено", + "empty": "Список покупок пуст", + "allPurchased": "Все куплено", + "clear": "Очистить", + "loadError": "Ошибка загрузки списка покупок", + "addError": "Ошибка добавления покупки", + "toggleError": "Ошибка изменения статуса", + "deleteError": "Ошибка удаления покупки", + "updateError": "Ошибка обновления покупки", + "markAllError": "Ошибка обновления списка", + "clearError": "Ошибка очистки списка" + }, + "confirm": { + "deleteItem": "Удалить покупку?", + "deleteItemMessage": "Покупка будет удалена из списка безвозвратно.", + "markAll": "Пометить все как купленные?", + "markAllMessage": "Все покупки в списке будут отмечены как купленные.", + "markButton": "Пометить", + "clearAll": "Очистить список?", + "clearAllMessage": "Все покупки будут удалены из списка безвозвратно." + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..0a27bc3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,5 +1,6 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import './i18n' import './index.css' import App from './App.tsx' diff --git a/frontend/src/pages/AdminPanel.tsx b/frontend/src/pages/AdminPanel.tsx index 5e4b4f9..643eee4 100644 --- a/frontend/src/pages/AdminPanel.tsx +++ b/frontend/src/pages/AdminPanel.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { authApi, familyApi } from '../api/client'; import { useStore } from '../store/useStore'; import { @@ -14,6 +15,7 @@ import { } from 'lucide-react'; export default function AdminPanel() { + const { t } = useTranslation(); const navigate = useNavigate(); const { user, logout: storeLogout } = useStore(); @@ -46,7 +48,7 @@ export default function AdminPanel() { const handleCreateFamily = async () => { if (!newFamilyName.trim() || !newFamilyPassword.trim()) { - alert('Заполните название и пароль семьи'); + alert(t('admin.fillNameAndPassword')); return; } @@ -56,19 +58,19 @@ export default function AdminPanel() { setNewFamilyPassword(''); loadFamilies(); } catch (err) { - alert('Ошибка создания семьи'); + alert(t('admin.createError')); console.error(err); } }; const handleDeleteFamily = async (id: number) => { - if (!confirm('Удалить семью?')) return; + if (!confirm(t('admin.deleteConfirm'))) return; try { await familyApi.delete(id); loadFamilies(); } catch (err) { - alert('Ошибка удаления семьи'); + alert(t('admin.deleteError')); console.error(err); } }; @@ -84,17 +86,17 @@ export default function AdminPanel() {

- Доступ запрещен + {t('admin.accessDenied')}

- Требуются права администратора + {t('admin.requiresAdmin')}

@@ -110,10 +112,10 @@ export default function AdminPanel() {

- Админ панель + {t('admin.title')}

- Управление семьями + {t('admin.subtitle')}

@@ -139,7 +141,7 @@ export default function AdminPanel() {

- Создать новую семью + {t('admin.createFamily')}

@@ -147,11 +149,11 @@ export default function AdminPanel() {
setNewFamilyName(e.target.value)} className="w-full px-5 py-4 border-2 border-gray-300 rounded-2xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all font-medium text-center" @@ -160,11 +162,11 @@ export default function AdminPanel() {
setNewFamilyPassword(e.target.value)} className="w-full px-5 py-4 border-2 border-gray-300 rounded-2xl focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-all font-medium text-center" @@ -175,7 +177,7 @@ export default function AdminPanel() { className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-linear-to-r from-green-500 to-green-600 text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold text-lg" > - Создать семью + {t('admin.createButton')}
@@ -186,7 +188,7 @@ export default function AdminPanel() {

- Список семей + {t('admin.familyList')}

@@ -196,10 +198,10 @@ export default function AdminPanel() {

- Семьи не найдены + {t('admin.noFamilies')}

- Создайте первую семью + {t('admin.createFirst')}

) : ( @@ -222,7 +224,7 @@ export default function AdminPanel() { className="w-full sm:w-auto flex items-center justify-center gap-2 px-5 py-3 bg-red-500 hover:bg-red-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg" > - Удалить + {t('common.delete')} ))} diff --git a/frontend/src/pages/FamilyView.tsx b/frontend/src/pages/FamilyView.tsx index 1231fab..57e7dd7 100644 --- a/frontend/src/pages/FamilyView.tsx +++ b/frontend/src/pages/FamilyView.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { categoryApi, expenseApi, inviteLinkApi } from '../api/client'; import { useStore } from '../store/useStore'; import type { Category, Expense, InviteLinkResponse } from '../types'; @@ -24,6 +25,7 @@ import { import ShoppingListModal from '../components/ShoppingListModal'; export default function FamilyView() { + const { t } = useTranslation(); const { familyId } = useParams<{ familyId: string }>(); const navigate = useNavigate(); const { selectedFamily } = useStore(); @@ -82,7 +84,7 @@ export default function FamilyView() { setRemainingLimits(limits); console.log('All data loaded successfully'); } catch (err: any) { - const errorMsg = err.response?.data?.message || err.message || 'Ошибка загрузки категорий'; + const errorMsg = err.response?.data?.message || err.message || t('family.loadError'); setError(errorMsg); console.error('Error loading categories:', err); } finally { @@ -103,28 +105,28 @@ export default function FamilyView() { setShowAddCategory(false); loadCategories(); } catch (err: any) { - const errorMsg = err.response?.data?.message || err.response?.statusText || err.message || 'Ошибка создания категории'; - alert(`Ошибка создания категории: ${errorMsg} (Статус: ${err.response?.status})`); + const errorMsg = err.response?.data?.message || err.response?.statusText || err.message || t('category.createError'); + alert(`${t('category.createError')}: ${errorMsg}`); console.error('Full error:', err); } }; const handleDeleteCategory = async (categoryId: number) => { if (!familyId) return; - if (!confirm('Удалить категорию?')) return; + if (!confirm(t('category.deleteConfirm'))) return; try { await categoryApi.delete(parseInt(familyId), categoryId); loadCategories(); } catch (err) { - alert('Ошибка удаления категории'); + alert(t('category.deleteError')); console.error(err); } }; const handleResetLimit = async (categoryId: number) => { if (!familyId) return; - if (!confirm('Удалить все траты по этой категории?')) return; + if (!confirm(t('category.resetConfirm'))) return; try { const expensesResponse = await expenseApi.getAllByCategory( @@ -142,7 +144,7 @@ export default function FamilyView() { loadCategories(); } catch (err) { - alert('Ошибка сброса трат'); + alert(t('category.resetError')); console.error(err); } }; @@ -160,7 +162,7 @@ export default function FamilyView() { setShowAddExpense(null); loadCategories(); } catch (err) { - alert('Ошибка добавления расхода'); + alert(t('expense.addError')); console.error(err); } }; @@ -181,7 +183,7 @@ export default function FamilyView() { setCategoryExpenses(response.data); setShowHistory(categoryId); } catch (err) { - alert('Ошибка загрузки истории трат'); + alert(t('expense.historyError')); console.error(err); } }; @@ -192,7 +194,7 @@ export default function FamilyView() { const response = await inviteLinkApi.create({ expires_in_hours: 168 }); setInviteLink(response.data); } catch (err) { - alert('Ошибка создания ссылки-приглашения'); + alert(t('invite.createError')); console.error(err); } finally { setInviteLoading(false); @@ -221,7 +223,7 @@ export default function FamilyView() {
- Загрузка... + {t('common.loading')}
); @@ -270,25 +272,25 @@ export default function FamilyView() { className="inline-flex items-center gap-2 px-4 py-2 bg-white/20 hover:bg-white/30 text-white rounded-2xl backdrop-blur-md mb-6 transition-all duration-300 group" > - Пригласить участника + {t('family.inviteMember')}

- {selectedFamily?.name || 'Семья'} + {selectedFamily?.name || t('family.defaultName')}

-

Общий лимит

+

{t('family.totalLimit')}

{getTotalLimit().toFixed(2)} ₽

-

Общий остаток

+

{t('family.totalRemaining')}

{getTotalRemaining().toFixed(2)} ₽

@@ -299,7 +301,7 @@ export default function FamilyView() { className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-gradient-to-r from-green-500 to-emerald-600 text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold" > - Список покупок + {t('family.shoppingList')}
@@ -341,21 +343,21 @@ export default function FamilyView() { className="flex items-center gap-2 px-4 py-2 bg-linear-to-r from-red-500 to-pink-500 text-white rounded-xl hover:shadow-lg transition-all duration-300 font-semibold whitespace-nowrap text-sm" > - Добавить расход - Расход + {t('category.addExpense')} + {t('category.expense')} )}
- Остаток: + {t('category.remaining')} {remaining.toFixed(2)} ₽
- Лимит: + {t('category.limit')} {limit.toFixed(2)} ₽
@@ -366,7 +368,7 @@ export default function FamilyView() { />

- {percentage.toFixed(0)}% осталось + {percentage.toFixed(0)}{t('category.percentRemaining')}

@@ -376,21 +378,21 @@ export default function FamilyView() { className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 bg-yellow-500 hover:bg-yellow-600 text-white rounded-xl transition-all font-semibold shadow-md hover:shadow-lg text-sm" > - Обнулить + {t('category.reset')} @@ -399,7 +401,7 @@ export default function FamilyView() {

- История трат + {t('expense.historyTitle')}

{categoryExpenses.length === 0 ? ( -

Нет трат

+

{t('expense.noExpenses')}

) : (
{categoryExpenses.map((expense) => ( @@ -448,12 +450,12 @@ export default function FamilyView() { {showAddExpense === category.id && (

- Добавить расход + {t('expense.addTitle')}

setExpenseDescription(e.target.value)} className="w-full px-4 py-3 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all" @@ -481,7 +483,7 @@ export default function FamilyView() { className="flex-1 flex items-center justify-center gap-2 px-5 py-3 bg-linear-to-r from-green-500 to-green-600 text-white rounded-2xl hover:shadow-xl transition-all font-semibold" > - Добавить + {t('common.add')}

- Управление категориями + {t('category.management')}

{showAddCategory ? (

- Новая категория + {t('category.newCategory')}

setNewCategoryName(e.target.value)} className="w-full px-5 py-4 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all font-medium" /> setNewCategoryLimit(e.target.value)} className="w-full px-5 py-4 border-2 border-gray-300 rounded-2xl focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all font-medium text-center" @@ -534,13 +536,13 @@ export default function FamilyView() { className="flex-1 flex items-center justify-center gap-2 px-6 py-4 bg-linear-to-r from-green-500 to-green-600 text-white rounded-2xl hover:shadow-xl transition-all font-semibold" > - Создать + {t('common.create')}
@@ -551,7 +553,7 @@ export default function FamilyView() { className="w-full flex items-center justify-center gap-2 px-6 py-4 bg-linear-to-r from-purple-600 to-blue-600 text-white rounded-2xl hover:shadow-xl transition-all duration-300 font-semibold" > - Добавить категорию + {t('category.addCategory')} )}
@@ -572,7 +574,7 @@ export default function FamilyView() {
-

Пригласить участника

+

{t('invite.title')}

@@ -609,7 +610,7 @@ export default function FamilyView() { ) : (

- Отправьте эту ссылку участнику, которого хотите пригласить: + {t('invite.sendLink')}

@@ -627,12 +628,12 @@ export default function FamilyView() { {copied ? ( <> - Скопировано! + {t('invite.copied')} ) : ( <> - Скопировать ссылку + {t('invite.copyLink')} )} diff --git a/frontend/src/pages/InvitePage.tsx b/frontend/src/pages/InvitePage.tsx index 9368dd2..ec84b38 100644 --- a/frontend/src/pages/InvitePage.tsx +++ b/frontend/src/pages/InvitePage.tsx @@ -1,10 +1,12 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { inviteLinkApi, authApi } from '../api/client'; import { useStore } from '../store/useStore'; import { Loader2, Users, UserPlus, AlertCircle } from 'lucide-react'; export default function InvitePage() { + const { t } = useTranslation(); const { token } = useParams<{ token: string }>(); const navigate = useNavigate(); const { isAuthenticated, setUser } = useStore(); @@ -37,10 +39,10 @@ export default function InvitePage() { setIsValid(true); setFamilyName(response.data.family_name); } else { - setError('Ссылка недействительна или срок её действия истёк'); + setError(t('invite.invalid')); } } catch (err) { - setError('Ссылка не найдена'); + setError(t('invite.notFound')); } finally { setLoading(false); } @@ -61,9 +63,9 @@ export default function InvitePage() { } } catch (err: any) { if (err.response?.status === 400) { - setError('Вы уже состоите в семье'); + setError(t('invite.alreadyInFamily')); } else { - setError('Ошибка при присоединении к семье'); + setError(t('invite.joinError')); } } finally { setJoining(false); @@ -78,7 +80,7 @@ export default function InvitePage() { const response = await authApi.getGoogleAuthUrl(window.location.href); window.location.href = response.data.url; } catch (err) { - setError('Ошибка при получении ссылки для авторизации'); + setError(t('login.authError')); } }; @@ -87,7 +89,7 @@ export default function InvitePage() {

- Проверка приглашения... + {t('invite.validating')}
); @@ -98,7 +100,7 @@ export default function InvitePage() {
- Присоединение к семье... + {t('invite.joining')}
); @@ -111,13 +113,13 @@ export default function InvitePage() {
-

Ошибка

+

{t('common.error')}

{error}

@@ -131,21 +133,20 @@ export default function InvitePage() {

- Приглашение в семью + {t('invite.pageTitle')}

{familyName}

- Вас пригласили присоединиться к семейному бюджету. - Войдите через Google, чтобы принять приглашение. + {t('invite.pageDescription')}

diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 138cae4..d8be09b 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,8 +1,10 @@ import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { authApi } from '../api/client'; import { Loader2, Wallet } from 'lucide-react'; export default function Login() { + const { t } = useTranslation(); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -14,7 +16,7 @@ export default function Login() { const response = await authApi.getGoogleAuthUrl(currentUrl); window.location.href = response.data.url; } catch (err) { - setError('Ошибка при получении ссылки для авторизации'); + setError(t('login.authError')); console.error(err); setLoading(false); } @@ -29,10 +31,10 @@ export default function Login() {

- Семейный бюджет + {t('login.title')}

- Войдите, чтобы продолжить + {t('login.subtitle')}

@@ -69,7 +71,7 @@ export default function Login() { d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" /> - Войти через Google + {t('login.googleButton')} )} diff --git a/frontend/src/pages/NoFamily.tsx b/frontend/src/pages/NoFamily.tsx index ac8dcb5..418be37 100644 --- a/frontend/src/pages/NoFamily.tsx +++ b/frontend/src/pages/NoFamily.tsx @@ -1,10 +1,12 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { useStore } from '../store/useStore'; import { authApi, familyApi, inviteLinkApi } from '../api/client'; import { Users, LogOut, Settings, Plus, Loader2, Eye, EyeOff } from 'lucide-react'; export default function NoFamily() { + const { t } = useTranslation(); const navigate = useNavigate(); const { user, logout, setUser } = useStore(); @@ -37,9 +39,9 @@ export default function NoFamily() { } } catch (err: any) { if (err.response?.status === 400) { - setError('Ссылка-приглашение недействительна или истекла'); + setError(t('noFamily.invalidInvite')); } else { - setError('Ошибка при присоединении к семье'); + setError(t('noFamily.joinError')); } } finally { setJoiningFamily(false); @@ -64,7 +66,7 @@ export default function NoFamily() { e.preventDefault(); if (!familyName.trim()) { - setError('Введите название семьи'); + setError(t('noFamily.enterFamilyName')); return; } @@ -87,12 +89,12 @@ export default function NoFamily() { if (err && typeof err === 'object' && 'response' in err) { const axiosError = err as { response?: { status?: number } }; if (axiosError.response?.status === 409) { - setError('Вы уже состоите в семье'); + setError(t('noFamily.alreadyInFamily')); } else { - setError('Ошибка при создании семьи'); + setError(t('noFamily.createError')); } } else { - setError('Ошибка при создании семьи'); + setError(t('noFamily.createError')); } console.error(err); } finally { @@ -105,7 +107,7 @@ export default function NoFamily() {
- Присоединение к семье... + {t('noFamily.joiningFamily')}
); @@ -120,7 +122,7 @@ export default function NoFamily() {

- Добро пожаловать! + {t('noFamily.welcome')}

{user?.email || user?.username} @@ -129,7 +131,7 @@ export default function NoFamily() {

- Создайте свою семью + {t('noFamily.createFamily')}

{error && ( @@ -141,14 +143,14 @@ export default function NoFamily() {
setFamilyName(e.target.value)} - placeholder="Например: Семья Ивановых" + placeholder={t('noFamily.familyNamePlaceholder')} className="w-full px-4 py-3 border-2 border-gray-300 rounded-xl focus:border-purple-500 focus:outline-none transition-colors" disabled={loading} /> @@ -156,7 +158,7 @@ export default function NoFamily() {
setPassword(e.target.value)} - placeholder="Для защиты доступа" + placeholder={t('noFamily.passwordPlaceholder')} className="w-full px-4 py-3 pr-12 border-2 border-gray-300 rounded-xl focus:border-purple-500 focus:outline-none transition-colors" disabled={loading} /> @@ -177,7 +179,7 @@ export default function NoFamily() {

- Пароль понадобится для доступа к бюджету семьи + {t('noFamily.passwordHint')}

@@ -191,7 +193,7 @@ export default function NoFamily() { ) : ( <> - Создать семью + {t('noFamily.createButton')} )} @@ -206,7 +208,7 @@ export default function NoFamily() { className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-2xl transition-all duration-300 font-medium" > - Админ панель + {t('noFamily.adminPanel')} )}
-- 2.49.1