import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode, } from 'react'; export type Language = 'ru' | 'en'; const STORAGE_KEY = 'family-wishlist-language'; const FALLBACK_LANGUAGE: Language = 'ru'; const translations = { ru: { 'app.name': 'Family Wishlist', 'language.ru': 'Русский', 'language.en': 'English', 'language.switch': 'Язык', 'common.loading': 'Загрузка...', 'common.cancel': 'Отмена', 'common.save': 'Сохранить', 'common.saveChanges': 'Сохранить изменения', 'common.backHome': 'На главную', 'common.days.one': '{count} день', 'common.days.few': '{count} дня', 'common.days.many': '{count} дней', 'footer.frontend': 'frontend v{version}', 'footer.backend': 'backend v{version}', 'header.active': 'Активные', 'header.fulfilled': 'Исполненные', 'header.archive': 'Архив', 'header.trash': 'Корзина', 'header.signedInAs': 'вошли как {name}', 'header.profileSettings': 'Настройки профиля', 'header.profile': 'Профиль', 'header.friendWishes': 'Желания {name}', 'header.logout': 'Выйти', 'login.title': 'С возвращением', 'login.description': 'Войдите, чтобы управлять списком желаний. Доступы задаются на сервере.', 'login.username': 'Логин', 'login.password': 'Пароль', 'login.submit': 'Войти', 'login.failed': 'Не удалось войти', 'dashboard.title': 'Ваш список желаний', 'dashboard.description': 'Добавляйте вещи, о которых мечтаете. Публичная страница доступна по адресу ', 'dashboard.addWish': 'Добавить желание', 'dashboard.emptyTitle': 'Желаний пока нет', 'dashboard.emptyText': 'Начните с того, что вам действительно хочется получить.', 'dashboard.addFirstWish': 'Добавить первое желание', 'archive.title': 'Архив', 'archive.description': 'Отложенные желания. Их видите только вы, и их можно вернуть в активный список.', 'archive.emptyTitle': 'Архив пуст', 'archive.emptyText': 'Архивированные желания появятся здесь.', 'completed.title': 'Исполненные', 'completed.description': 'Желания, которые уже исполнились. Из любого можно создать новое.', 'completed.emptyTitle': 'Исполненных желаний пока нет', 'completed.emptyText': 'Когда желание исполнится, отметьте его, и оно появится здесь.', 'trash.title': 'Корзина', 'trash.description': 'Удалённые желания хранятся {days}, затем удаляются навсегда.', 'trash.emptyTitle': 'Корзина пуста', 'trash.emptyText': 'Удалённые желания будут появляться здесь на {days}.', 'trash.autoRemove': 'Автоудаление через {days}', 'profile.title': 'Профиль', 'profile.publicPage': 'Публичная страница доступна по адресу ', 'profile.slug': 'Slug (публичный URL)', 'profile.displayName': 'Отображаемое имя', 'profile.bio': 'О себе', 'profile.avatarUrl': 'Ссылка на аватар', 'profile.avatar': 'Аватар', 'profile.avatarHint': 'Можно вставить ссылку или загрузить фото до 2 MB.', 'profile.uploadAvatar': 'Загрузить фото', 'profile.avatarTooLarge': 'Фото должно быть не больше 2 MB', 'profile.avatarUnsupported': 'Поддерживаются JPEG, PNG, WebP и GIF', 'profile.saved': 'Профиль сохранён', 'profile.avatarUploaded': 'Аватар обновлён', 'profile.saveFailed': 'Не удалось сохранить', 'public.loadingWishes': 'Загрузка желаний...', 'public.notFoundTitle': 'Профиль не найден', 'public.notFoundText': 'Проверьте ссылку и попробуйте ещё раз. Slug чувствителен к регистру.', 'public.backToMine': 'Вернуться к моим желаниям', 'public.wishlistTitle': 'Список желаний {name}', 'public.emptyTitle': 'Желаний пока нет', 'public.emptyText': 'Загляните позже!', 'notFound.text': 'Не удалось найти эту страницу.', 'protected.loading': 'Загрузка...', 'wish.action.edit': 'Редактировать', 'wish.action.complete': 'Отметить исполненным', 'wish.action.archive': 'В архив', 'wish.action.delete': 'Удалить', 'wish.action.restore': 'Восстановить', 'wish.action.duplicate': 'Создать копию как новое', 'wish.actions': 'Действия', 'wish.openLink': 'открыть ссылку', 'wish.badge.new': 'новое', 'wish.badge.fulfilled': 'исполнено', 'wish.badge.archived': 'архив', 'wish.badge.trash': 'корзина', 'wishForm.addTitle': 'Добавить желание', 'wishForm.editTitle': 'Редактировать желание', 'wishForm.addDescription': 'Расскажите, чего хочется. По ссылке мы попробуем подтянуть картинку.', 'wishForm.editDescription': 'Обновите детали желания.', 'wishForm.title': 'Название', 'wishForm.titlePlaceholder': 'Гейзерная кофеварка, размер 3', 'wishForm.price': 'Цена (необязательно)', 'wishForm.pricePlaceholder': 'например, 2490', 'wishForm.currency': 'Валюта', 'wishForm.link': 'Ссылка (необязательно)', 'wishForm.linkHint': 'После сохранения попробуем подтянуть превью-картинку по ссылке.', 'wishForm.comment': 'Комментарий (необязательно)', 'wishForm.commentPlaceholder': 'Размер / цвет / заметки...', 'wishForm.image': 'Картинка', 'wishForm.uploadCustom': 'Загрузить свою', 'wishForm.refreshFromLink': 'Обновить по ссылке', 'wishForm.resetImage': 'Сбросить на стандартную', 'wishForm.addSubmit': 'Добавить желание', 'toast.genericError': 'Что-то пошло не так', 'toast.wishAdded': 'Желание добавлено', 'toast.saved': 'Сохранено', 'toast.archived': 'Перемещено в архив', 'toast.fulfilled': 'Отмечено исполненным', 'toast.deleted': 'Перемещено в корзину (30 дней на восстановление)', 'toast.restored': 'Восстановлено', 'toast.duplicated': 'Новое желание создано из исполненного', 'toast.imageUpdated': 'Картинка обновлена', 'toast.imageRefreshed': 'Картинка обновлена по ссылке', 'toast.imageFetchFailed': 'Не удалось получить картинку по ссылке', 'toast.imageReset': 'Картинка сброшена на стандартную', 'validation.invalid': 'Проверьте значение поля', 'validation.slug': 'Slug: 3-32 символа, строчные латинские буквы, цифры и дефисы', }, en: { 'app.name': 'Family Wishlist', 'language.ru': 'Русский', 'language.en': 'English', 'language.switch': 'Language', 'common.loading': 'Loading...', 'common.cancel': 'Cancel', 'common.save': 'Save', 'common.saveChanges': 'Save changes', 'common.backHome': 'Back to home', 'common.days.one': '{count} day', 'common.days.few': '{count} days', 'common.days.many': '{count} days', 'footer.frontend': 'frontend v{version}', 'footer.backend': 'backend v{version}', 'header.active': 'Active', 'header.fulfilled': 'Fulfilled', 'header.archive': 'Archive', 'header.trash': 'Trash', 'header.signedInAs': 'signed in as {name}', 'header.profileSettings': 'Profile settings', 'header.profile': 'Profile', 'header.friendWishes': "{name}'s wishes", 'header.logout': 'Log out', 'login.title': 'Welcome back', 'login.description': 'Sign in to manage your wishlist. Credentials are set up via the server environment.', 'login.username': 'Username', 'login.password': 'Password', 'login.submit': 'Sign in', 'login.failed': 'Login failed', 'dashboard.title': 'Your wishlist', 'dashboard.description': 'Add things you dream of. Share your public page at ', 'dashboard.addWish': 'Add wish', 'dashboard.emptyTitle': 'No wishes yet', 'dashboard.emptyText': "Start by adding something you'd love to receive.", 'dashboard.addFirstWish': 'Add your first wish', 'archive.title': 'Archive', 'archive.description': 'Wishes you put aside. Only you see this. Restore them to your active list any time.', 'archive.emptyTitle': 'Archive is empty', 'archive.emptyText': 'Archived wishes will show up here.', 'completed.title': 'Fulfilled', 'completed.description': "Wishes you've received. You can create a new wish based on any of them.", 'completed.emptyTitle': 'Nothing fulfilled yet', 'completed.emptyText': 'When a wish comes true, mark it as fulfilled and it lands here.', 'trash.title': 'Trash', 'trash.description': 'Deleted wishes are kept for {days}, then permanently removed.', 'trash.emptyTitle': 'Trash is empty', 'trash.emptyText': 'Deleted wishes will appear here for {days}.', 'trash.autoRemove': 'Auto-removes in {days}', 'profile.title': 'Profile', 'profile.publicPage': 'Your public page lives at ', 'profile.slug': 'Slug (public URL)', 'profile.displayName': 'Display name', 'profile.bio': 'Bio', 'profile.avatarUrl': 'Avatar URL', 'profile.avatar': 'Avatar', 'profile.avatarHint': 'Paste a link or upload a photo up to 2 MB.', 'profile.uploadAvatar': 'Upload photo', 'profile.avatarTooLarge': 'Avatar must be 2 MB or less', 'profile.avatarUnsupported': 'JPEG, PNG, WebP and GIF are supported', 'profile.saved': 'Profile saved', 'profile.avatarUploaded': 'Avatar updated', 'profile.saveFailed': 'Save failed', 'public.loadingWishes': 'Loading wishes...', 'public.notFoundTitle': 'Profile not found', 'public.notFoundText': 'Check the link and try again. Slugs are case-sensitive.', 'public.backToMine': 'Back to my wishlist', 'public.wishlistTitle': "{name}'s wishlist", 'public.emptyTitle': 'No wishes yet', 'public.emptyText': 'Check back later!', 'notFound.text': "We couldn't find that page.", 'protected.loading': 'Loading...', 'wish.action.edit': 'Edit', 'wish.action.complete': 'Mark fulfilled', 'wish.action.archive': 'Archive', 'wish.action.delete': 'Delete', 'wish.action.restore': 'Restore', 'wish.action.duplicate': 'Create copy as new', 'wish.actions': 'Actions', 'wish.openLink': 'open link', 'wish.badge.new': 'new', 'wish.badge.fulfilled': 'fulfilled', 'wish.badge.archived': 'archived', 'wish.badge.trash': 'trash', 'wishForm.addTitle': 'Add a wish', 'wishForm.editTitle': 'Edit wish', 'wishForm.addDescription': 'Tell us what you want. A link helps us grab a preview image automatically.', 'wishForm.editDescription': 'Update the details of your wish.', 'wishForm.title': 'Title', 'wishForm.titlePlaceholder': 'Moka pot, size 3', 'wishForm.price': 'Price (optional)', 'wishForm.pricePlaceholder': 'e.g. 2490', 'wishForm.currency': 'Currency', 'wishForm.link': 'Link (optional)', 'wishForm.linkHint': 'We will try to pull a preview image from the link after saving.', 'wishForm.comment': 'Comment (optional)', 'wishForm.commentPlaceholder': 'Size / color / notes...', 'wishForm.image': 'Image', 'wishForm.uploadCustom': 'Upload custom', 'wishForm.refreshFromLink': 'Refresh from link', 'wishForm.resetImage': 'Reset to default', 'wishForm.addSubmit': 'Add wish', 'toast.genericError': 'Something went wrong', 'toast.wishAdded': 'Wish added', 'toast.saved': 'Saved', 'toast.archived': 'Moved to archive', 'toast.fulfilled': 'Marked as fulfilled', 'toast.deleted': 'Moved to trash (30 days to restore)', 'toast.restored': 'Restored', 'toast.duplicated': 'New wish created from the fulfilled one', 'toast.imageUpdated': 'Image updated', 'toast.imageRefreshed': 'Image refreshed from link', 'toast.imageFetchFailed': 'Could not fetch image from link', 'toast.imageReset': 'Reset to default image', 'validation.invalid': 'Check this field', 'validation.slug': 'Slug must be 3-32 chars, lowercase letters, digits, hyphens', }, } as const; export type TranslationKey = keyof typeof translations.ru; interface I18nContextValue { language: Language; locale: string; setLanguage: (language: Language) => void; t: (key: TranslationKey, vars?: Record) => string; dayCount: (count: number) => string; } const I18nContext = createContext(null); function isLanguage(value: string | null): value is Language { return value === 'ru' || value === 'en'; } function detectLanguage(): Language { if (typeof window === 'undefined') return FALLBACK_LANGUAGE; const saved = window.localStorage.getItem(STORAGE_KEY); if (isLanguage(saved)) return saved; const languages = navigator.languages.length ? navigator.languages : [navigator.language]; for (const lang of languages) { const normalized = lang.toLowerCase(); if (normalized.startsWith('ru')) return 'ru'; if (normalized.startsWith('en')) return 'en'; } return FALLBACK_LANGUAGE; } function interpolate(template: string, vars?: Record): string { if (!vars) return template; return template.replace(/\{(\w+)\}/g, (_, key: string) => String(vars[key] ?? `{${key}}`)); } function dayKey(language: Language, count: number): TranslationKey { if (language === 'en') return count === 1 ? 'common.days.one' : 'common.days.many'; const mod10 = count % 10; const mod100 = count % 100; if (mod10 === 1 && mod100 !== 11) return 'common.days.one'; if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'common.days.few'; return 'common.days.many'; } export function I18nProvider({ children }: { children: ReactNode }) { const [language, setLanguageState] = useState(() => detectLanguage()); useEffect(() => { document.documentElement.lang = language; window.localStorage.setItem(STORAGE_KEY, language); }, [language]); const setLanguage = useCallback((next: Language) => setLanguageState(next), []); const t = useCallback( (key: TranslationKey, vars?: Record) => interpolate(translations[language][key] ?? translations[FALLBACK_LANGUAGE][key], vars), [language], ); const dayCount = useCallback( (count: number) => t(dayKey(language, count), { count }), [language, t], ); const value = useMemo( () => ({ language, locale: language === 'ru' ? 'ru-RU' : 'en-US', setLanguage, t, dayCount, }), [dayCount, language, setLanguage, t], ); return {children}; } export function useI18n(): I18nContextValue { const value = useContext(I18nContext); if (!value) throw new Error('useI18n must be used within I18nProvider'); return value; } export function translateValidation(t: I18nContextValue['t'], message: string | undefined): string { if (!message) return t('validation.invalid'); if (message.includes('Slug must be')) return t('validation.slug'); return t('validation.invalid'); }