From 5466c208bfbc0eb5283ec51970a75b143e735653 Mon Sep 17 00:00:00 2001 From: symonbaikov Date: Mon, 8 Sep 2025 22:05:48 +0300 Subject: [PATCH] Replace i18next with Intlayer and add RTL support for hebrew and arabic --- .eslintrc.js | 29 - .babelrc.cjs | 3 - .gitignore | 3 + apps/api/index.js | 14 + apps/client/src/18n.ts | 26 - apps/client/src/App.jsx | 50 +- apps/client/src/components/BillingPage.jsx | 32 +- .../src/components/CancelSubscription.jsx | 24 +- apps/client/src/components/JobCard.jsx | 45 +- apps/client/src/components/JobListing.jsx | 23 +- apps/client/src/components/Navbar.jsx | 148 ++++-- apps/client/src/components/PremiumPage.jsx | 81 ++- apps/client/src/components/SupportPage.jsx | 98 ++-- apps/client/src/components/UserJobs.jsx | 87 +-- apps/client/src/components/UserProfile.jsx | 18 +- .../src/components/form/AddSeekerModal.jsx | 70 +-- .../src/components/form/EditJobFields.jsx | 24 +- .../src/components/form/EditJobForm.jsx | 14 +- apps/client/src/components/form/JobForm.jsx | 10 +- .../src/components/form/JobFormFields.jsx | 40 +- apps/client/src/components/ui/ImageUpload.jsx | 32 +- .../src/components/ui/JobFilterModal.jsx | 47 +- .../client/src/components/ui/MailDropdown.jsx | 12 +- .../src/components/ui/NewsletterAdmin.jsx | 8 +- .../src/components/ui/NewsletterModal.jsx | 109 ++-- .../src/components/ui/SeekerFilterModal.jsx | 93 ++-- .../src/components/ui/VerificationModal.jsx | 46 +- apps/client/src/components/ui/button.jsx | 8 +- .../src/components/ui/city-dropwdown.jsx | 28 +- .../src/components/ui/premium-button.jsx | 6 +- .../src/content/addSeekerModal.content.tsx | 240 +++++++++ .../src/content/billingPage.content.tsx | 107 ++++ apps/client/src/content/button.content.tsx | 23 + .../client/src/content/cancelPage.content.tsx | 44 ++ .../content/cancelSubscription.content.tsx | 93 ++++ .../src/content/cityDropdown.content.tsx | 23 + apps/client/src/content/common.content.tsx | 399 ++++++++++++++ .../src/content/createNewAd.content.tsx | 23 + .../src/content/editJobFields.content.tsx | 79 +++ .../src/content/editJobForm.content.tsx | 45 ++ apps/client/src/content/home.content.tsx | 66 +++ .../src/content/imageUpload.content.tsx | 23 + .../content/imageUploadComponent.content.tsx | 100 ++++ apps/client/src/content/jobCard.content.tsx | 86 +++ .../src/content/jobFilterModal.content.tsx | 79 +++ apps/client/src/content/jobForm.content.tsx | 30 ++ .../src/content/jobFormFields.content.tsx | 135 +++++ .../client/src/content/jobListing.content.tsx | 86 +++ .../src/content/mailDropdown.content.tsx | 23 + apps/client/src/content/navbar.content.tsx | 105 ++++ .../src/content/newsletterAdmin.content.tsx | 30 ++ .../src/content/newsletterModal.content.tsx | 293 ++++++++++ .../newsletterSubscription.content.tsx | 349 ++++++++++++ .../src/content/notFoundPage.content.tsx | 23 + .../src/content/premiumButton.content.tsx | 16 + .../src/content/premiumPage.content.tsx | 258 +++++++++ .../src/content/seekerDetails.content.tsx | 142 +++++ .../src/content/seekerFilterModal.content.tsx | 218 ++++++++ apps/client/src/content/seekers.content.tsx | 268 ++++++++++ .../src/content/supportPage.content.tsx | 324 +++++++++++ .../content/translationHelpers.content.tsx | 454 ++++++++++++++++ apps/client/src/content/userJobs.content.tsx | 206 +++++++ .../src/content/userProfile.content.tsx | 51 ++ .../src/content/verificationModal.content.tsx | 135 +++++ .../src/contexts/ImageUploadContext.jsx | 8 +- apps/client/src/hooks/useFetchCategories.js | 8 +- apps/client/src/hooks/useFetchCities.js | 8 +- .../src/hooks/useI18nHTMLAttributes.tsx | 68 +++ apps/client/src/hooks/useJobs.js | 8 +- apps/client/src/hooks/useLanguageManager.js | 144 +++++ apps/client/src/hooks/useSeekers.js | 8 +- apps/client/src/pages/Cancel.jsx | 14 +- apps/client/src/pages/CreateNewAd.jsx | 8 +- apps/client/src/pages/Home.jsx | 50 +- .../src/pages/NewsletterSubscription.jsx | 125 +++-- apps/client/src/pages/NotFoundPage.jsx | 8 +- apps/client/src/pages/SeekerDetails.jsx | 48 +- apps/client/src/pages/Seekers.jsx | 83 ++- apps/client/src/store/languageStore.ts | 9 +- apps/client/src/utils/translationHelpers.js | 158 +++--- docs/structure.md | 69 ++- intlayer.config.ts | 50 ++ jest.config.cjs | 8 - jest.setup.js | 1 - package-lock.json | 501 ++++++++++++++++-- package.json | 7 +- public/locales/ar/translation.json | 426 --------------- public/locales/en/translation.json | 429 --------------- public/locales/he/translation.json | 420 --------------- public/locales/ru/translation.json | 428 --------------- public/locales/uk/translation.json | 431 --------------- tests/hooks.test.jsx | 19 +- tests/job-card.test.jsx | 62 ++- tests/utils.test.jsx | 69 ++- tools/test-language-loading.js | 52 ++ tsconfig.app.json | 5 +- vite.config.js | 3 +- 97 files changed, 6413 insertions(+), 3126 deletions(-) delete mode 100644 .eslintrc.js delete mode 100644 .babelrc.cjs delete mode 100755 apps/client/src/18n.ts create mode 100644 apps/client/src/content/addSeekerModal.content.tsx create mode 100644 apps/client/src/content/billingPage.content.tsx create mode 100644 apps/client/src/content/button.content.tsx create mode 100644 apps/client/src/content/cancelPage.content.tsx create mode 100644 apps/client/src/content/cancelSubscription.content.tsx create mode 100644 apps/client/src/content/cityDropdown.content.tsx create mode 100644 apps/client/src/content/common.content.tsx create mode 100644 apps/client/src/content/createNewAd.content.tsx create mode 100644 apps/client/src/content/editJobFields.content.tsx create mode 100644 apps/client/src/content/editJobForm.content.tsx create mode 100644 apps/client/src/content/home.content.tsx create mode 100644 apps/client/src/content/imageUpload.content.tsx create mode 100644 apps/client/src/content/imageUploadComponent.content.tsx create mode 100644 apps/client/src/content/jobCard.content.tsx create mode 100644 apps/client/src/content/jobFilterModal.content.tsx create mode 100644 apps/client/src/content/jobForm.content.tsx create mode 100644 apps/client/src/content/jobFormFields.content.tsx create mode 100644 apps/client/src/content/jobListing.content.tsx create mode 100644 apps/client/src/content/mailDropdown.content.tsx create mode 100644 apps/client/src/content/navbar.content.tsx create mode 100644 apps/client/src/content/newsletterAdmin.content.tsx create mode 100644 apps/client/src/content/newsletterModal.content.tsx create mode 100644 apps/client/src/content/newsletterSubscription.content.tsx create mode 100644 apps/client/src/content/notFoundPage.content.tsx create mode 100644 apps/client/src/content/premiumButton.content.tsx create mode 100644 apps/client/src/content/premiumPage.content.tsx create mode 100644 apps/client/src/content/seekerDetails.content.tsx create mode 100644 apps/client/src/content/seekerFilterModal.content.tsx create mode 100644 apps/client/src/content/seekers.content.tsx create mode 100644 apps/client/src/content/supportPage.content.tsx create mode 100644 apps/client/src/content/translationHelpers.content.tsx create mode 100644 apps/client/src/content/userJobs.content.tsx create mode 100644 apps/client/src/content/userProfile.content.tsx create mode 100644 apps/client/src/content/verificationModal.content.tsx create mode 100644 apps/client/src/hooks/useI18nHTMLAttributes.tsx create mode 100644 apps/client/src/hooks/useLanguageManager.js create mode 100644 intlayer.config.ts delete mode 100644 jest.config.cjs delete mode 100644 jest.setup.js delete mode 100644 public/locales/ar/translation.json delete mode 100755 public/locales/en/translation.json delete mode 100644 public/locales/he/translation.json delete mode 100755 public/locales/ru/translation.json delete mode 100755 public/locales/uk/translation.json create mode 100644 tools/test-language-loading.js diff --git a/ .eslintrc.js b/ .eslintrc.js deleted file mode 100644 index ad7ff24..0000000 --- a/ .eslintrc.js +++ /dev/null @@ -1,29 +0,0 @@ -export default { - env: { - browser: true, - es2021: true, - jest: true, - }, - extends: [ - 'eslint:recommended', - 'plugin:react/recommended', - 'plugin:jest/recommended', - 'plugin:testing-library/react', - ], - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - ecmaVersion: 'latest', - sourceType: 'module', - }, - plugins: ['react', 'jest', 'testing-library'], - rules: { - 'react/prop-types': 'off', - }, - settings: { - react: { - version: 'detect', - }, - }, - }; \ No newline at end of file diff --git a/.babelrc.cjs b/.babelrc.cjs deleted file mode 100644 index b042a5d..0000000 --- a/.babelrc.cjs +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - presets: ["@babel/preset-env", "@babel/preset-react"] - }; \ No newline at end of file diff --git a/.gitignore b/.gitignore index e152876..bc70bb8 100755 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ dist-ssr *.sln *.sw? ._* + +# Ignore the files generated by Intlayer +.intlayer diff --git a/apps/api/index.js b/apps/api/index.js index 16a5cca..893fa59 100755 --- a/apps/api/index.js +++ b/apps/api/index.js @@ -3,6 +3,7 @@ import express from 'express'; import dotenv from 'dotenv'; import path from "path"; import { fileURLToPath } from "url" +import cors from 'cors'; import paymentRoutes from './routes/payments.js'; import jobsRoutes from './routes/jobs.js'; import citiesRoutes from './routes/cities.js'; @@ -27,6 +28,19 @@ dotenv.config(); const app = express(); const PORT = process.env.PORT || 3001; +// CORS configuration +app.use(cors({ + origin: [ + 'http://localhost:3000', + 'http://localhost:3001', + 'https://worknow.co.il', + 'https://www.worknow.co.il' + ], + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'x-intlayer-locale'] +})); + // Проверяем важные переменные окружения if (!process.env.DATABASE_URL) { console.error('❌ Missing DATABASE_URL!'); diff --git a/apps/client/src/18n.ts b/apps/client/src/18n.ts deleted file mode 100755 index 542955b..0000000 --- a/apps/client/src/18n.ts +++ /dev/null @@ -1,26 +0,0 @@ -import i18n from "i18next"; -import Backend from "i18next-http-backend"; -import LanguageDetector from "i18next-browser-languagedetector"; -import { initReactI18next } from "react-i18next"; - -i18n - .use(Backend) - .use(LanguageDetector) - .use(initReactI18next) - .init({ - fallbackLng: "ru", - debug: true, - supportedLngs: ["en", "ru", "uk", "he", "ar"], - detection: { - order: ["queryString", "cookie"], - caches: ["cookie"], - }, - interpolation: { - escapeValue: false, - }, - backend: { - loadPath: '/locales/{{lng}}/translation.json', - }, - }); - -export default i18n; diff --git a/apps/client/src/App.jsx b/apps/client/src/App.jsx index 130a04c..1e868a3 100644 --- a/apps/client/src/App.jsx +++ b/apps/client/src/App.jsx @@ -2,11 +2,13 @@ import { useMemo, Suspense, useEffect } from "react"; import { Toaster } from "react-hot-toast"; import { ClerkProvider } from "@clerk/clerk-react"; import { baseTheme } from "@clerk/themes"; +import { ruRU, enUS, heIL, arSA, ukUA } from "@clerk/localizations"; import { RouterProvider, Outlet, createBrowserRouter } from "react-router-dom"; -import useLanguageStore from "./store/languageStore.ts"; import { HelmetProvider, Helmet } from "react-helmet-async"; // 🔹 SEO import { ImageUploadProvider } from "./contexts/ImageUploadContext.jsx"; import { LoadingProvider } from "./contexts/LoadingContext.jsx"; +import { IntlayerProvider, useIntlayer, useLocale } from "react-intlayer"; +import { useI18nHTMLAttributes } from "./hooks/useI18nHTMLAttributes"; import ProgressBar from "./components/ui/ProgressBar.jsx"; import Home from "./pages/Home.jsx"; import MyAds from "./pages/MyAds.jsx"; @@ -25,7 +27,6 @@ import SeekerDetails from "./pages/SeekerDetails.jsx"; import PremiumPage from "./components/PremiumPage.jsx"; import { Navbar } from "./components/Navbar.jsx"; import { Footer } from "./components/Footer.jsx"; -import "./18n.ts"; import "./css/ripple.css"; import CancelSubscription from "./components/CancelSubscription.jsx"; import BillingPage from "./components/BillingPage.jsx"; @@ -109,12 +110,23 @@ if (!PUBLISHABLE_KEY || !PUBLISHABLE_KEY.startsWith('pk_')) { console.error('❌ Ошибка: Некорректный или отсутствует VITE_CLERK_PUBLISHABLE_KEY! Проверьте .env и перезапустите dev-сервер.'); } -const App = () => { - const localization = useLanguageStore((state) => state.localization); - const loading = useLanguageStore((state) => state.loading); - const currentLang = useLanguageStore((state) => state.language) || 'ru'; +// Clerk localization mapping +const clerkLocalizationMap = { + 'ru': ruRU, + 'en': enUS, + 'he': heIL, + 'ar': arSA, + 'uk': ukUA, +}; - const memoizedLocalization = useMemo(() => localization ?? {}, [localization]); +const AppContent = () => { + const { locale } = useLocale(); + + // Apply the hook to update the tag's lang and dir attributes based on the locale. + useI18nHTMLAttributes(); + + // Get the appropriate Clerk localization based on current locale + const clerkLocalization = clerkLocalizationMap[locale] || ruRU; // Default to Russian // Initialize Google Analytics on app load useEffect(() => { @@ -146,28 +158,18 @@ const App = () => { return
❌ Ошибка: Некорректный или отсутствует VITE_CLERK_PUBLISHABLE_KEY!
Проверьте .env и перезапустите dev-сервер.
Текущий ключ: {String(PUBLISHABLE_KEY)}
; } - if (loading) { - return
-
-
-
-
-
; - } - return ( - {/* 🔹 Глобальная SEO-оптимизация */} - + {/* 🔹 Глобальная SEO-оптимизация */} + WorkNow – Работа в Израиле | Поиск вакансий @@ -241,4 +243,12 @@ const App = () => { ); }; +const App = () => { + return ( + + + + ); +}; + export default App; diff --git a/apps/client/src/components/BillingPage.jsx b/apps/client/src/components/BillingPage.jsx index 8f40465..128d750 100644 --- a/apps/client/src/components/BillingPage.jsx +++ b/apps/client/src/components/BillingPage.jsx @@ -3,13 +3,13 @@ import { useClerk } from "@clerk/clerk-react"; import axios from "axios"; import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; -import { useTranslation } from 'react-i18next'; +import { useIntlayer } from "react-intlayer"; const API_URL = import.meta.env.VITE_API_URL; const PAGE_SIZE = 10; const BillingPage = () => { - const { t } = useTranslation(); + const content = useIntlayer("billingPage"); const { user } = useUser(); const { redirectToSignIn } = useClerk(); const [history, setHistory] = useState([]); @@ -32,7 +32,7 @@ const BillingPage = () => { setHasNext((res.data.payments || []).length === PAGE_SIZE); setHasPrev(page > 0); } catch { - setError("Ошибка загрузки истории платежей"); + setError(content.error_loading_history); setHistory([]); setHasNext(false); setHasPrev(page > 0); @@ -67,40 +67,40 @@ const BillingPage = () => { return (
-

Выставление счетов

+

{content.billing_title}

- Отмена подписки + {content.cancel_subscription}
-
История платежей
+
{content.payment_history}
{!user ? (
- Нет истории транзакций. Пожалуйста,{' '} + {content.no_transaction_history}{' '}
) : loading ? ( -
{t('loading')}
+
{content.loading}
) : error ? (
{error}
) : history.length === 0 ? ( -
Нет платежей
+
{content.no_payments}
) : ( <>
- - - - + + + + @@ -131,12 +131,12 @@ const BillingPage = () => {
diff --git a/apps/client/src/components/CancelSubscription.jsx b/apps/client/src/components/CancelSubscription.jsx index 3907af6..82a58a1 100644 --- a/apps/client/src/components/CancelSubscription.jsx +++ b/apps/client/src/components/CancelSubscription.jsx @@ -3,12 +3,12 @@ import { useUser } from "@clerk/clerk-react"; import { useClerk } from "@clerk/clerk-react"; import axios from "axios"; import { useState, useEffect } from "react"; -import { useTranslation } from 'react-i18next'; +import { useIntlayer } from "react-intlayer"; const API_URL = import.meta.env.VITE_API_URL; const CancelSubscription = () => { - const { t } = useTranslation(); + const content = useIntlayer("cancelSubscription"); const { user } = useUser(); const { redirectToSignIn } = useClerk(); const [loading, setLoading] = useState(false); @@ -46,7 +46,7 @@ const CancelSubscription = () => { }); await fetchUserStatus(); } catch (e) { - setError("Ошибка при отмене подписки. Попробуйте позже."); + setError(content.error_cancel_subscription); } finally { setLoading(false); } @@ -62,7 +62,7 @@ const CancelSubscription = () => { }); await fetchUserStatus(); } catch (e) { - setRenewError("Ошибка при возобновлении подписки. Попробуйте позже."); + setRenewError(content.error_renew_subscription); } finally { setRenewLoading(false); } @@ -70,22 +70,22 @@ const CancelSubscription = () => { return (
-

Отмена подписки

+

{content.cancel_subscription_title}

{!user ? (
- Нет действующей подписки. Пожалуйста,{' '} + {content.no_active_subscription}{' '}
) : ( <> {isPremium && isAutoRenewal === false && ( -
Автопродление успешно отключено!
+
{content.auto_renewal_disabled}
)} {isPremium && isAutoRenewal === false && ( )} {renewError &&
{renewError}
} {isPremium && isAutoRenewal && ( <> -

Вы действительно хотите отменить автопродление подписки?

+

{content.confirm_cancel_auto_renewal}

{error &&
{error}
} )} {!isPremium && isPremium !== null && ( -
{t('no_premium_subscription')}
+
{content.no_premium_subscription}
)} )} diff --git a/apps/client/src/components/JobCard.jsx b/apps/client/src/components/JobCard.jsx index b2de977..9182139 100755 --- a/apps/client/src/components/JobCard.jsx +++ b/apps/client/src/components/JobCard.jsx @@ -1,16 +1,18 @@ import PropTypes from "prop-types"; import { useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; +import { useIntlayer } from "react-intlayer"; import { useState } from "react"; import useFetchCities from '../hooks/useFetchCities'; +import useFetchCategories from '../hooks/useFetchCategories'; import Skeleton from 'react-loading-skeleton'; import 'react-loading-skeleton/dist/skeleton.css'; import { ImageModal } from './ui'; const JobCard = ({ job }) => { - const { t } = useTranslation(); + const content = useIntlayer("jobCard"); const navigate = useNavigate(); - const { cities, loading } = useFetchCities(); + const { cities, loading: citiesLoading } = useFetchCities(); + const { categories, loading: categoriesLoading } = useFetchCategories(); const [showImageModal, setShowImageModal] = useState(false); const [imageLoading, setImageLoading] = useState(true); @@ -21,6 +23,13 @@ const JobCard = ({ job }) => { cityLabel = city?.label || city?.name || null; } + // Получаем название категории на нужном языке + let categoryLabel = null; + if (job.categoryId && Array.isArray(categories)) { + const category = categories.find(c => c.value === job.categoryId || c.id === job.categoryId); + categoryLabel = category?.label || category?.name || null; + } + const handleImageClick = (e) => { e.stopPropagation(); // Prevent card click when clicking image setShowImageModal(true); @@ -61,40 +70,46 @@ const JobCard = ({ job }) => { {/* Плашка Премиум */} {job.user?.isPremium && (
- {t('premium_badge')} + {content.premiumBadge.value}
)}
{job.title}
- {job.category?.label && ( + {(job.categoryId || job.category?.label) && (
- {job.category.label} + {categoriesLoading ? ( + + ) : ( + + {categoryLabel || job.category?.label || content.notSpecified.value} + + )}
)}

- {t("salary_per_hour_card")}{" "} - {job.salary || "Не указано"} + {content.salaryPerHourCard.value}{" "} + {job.salary || content.notSpecified.value}
- {t("location_card")}{" "} - {loading ? ( + {content.locationCard.value}{" "} + {citiesLoading ? ( ) : ( - cityLabel || "Не указано" + cityLabel || content.notSpecified.value )}

-

{job.description || "Описание отсутствует"}

+

{job.description || content.descriptionMissing.value}

{typeof job.shuttle === 'boolean' && (
- {t("shuttle") || "Подвозка"}: {job.shuttle ? t("yes") || "да" : t("no") || "нет"} + {content.shuttle.value}: {job.shuttle ? content.yes.value : content.no.value}
)} {typeof job.meals === 'boolean' && (
- {t("meals") || "Питание"}: {job.meals ? t("yes") || "да" : t("no") || "нет"} + {content.meals.value}: {job.meals ? content.yes.value : content.no.value}
)} - {t("phone_number_card")} {job.phone || "Не указан"} + {content.phoneNumberCard.value} {job.phone || content.phoneNotSpecified.value}
{/* Image displayed under phone number in mini size */} diff --git a/apps/client/src/components/JobListing.jsx b/apps/client/src/components/JobListing.jsx index 780fbd1..c4ec486 100755 --- a/apps/client/src/components/JobListing.jsx +++ b/apps/client/src/components/JobListing.jsx @@ -3,18 +3,19 @@ import useJobs from '../hooks/useJobs'; import JobList from './JobList'; import PaginationControl from './PaginationControl'; import CityDropdown from './ui/city-dropwdown'; -import { useTranslation } from "react-i18next"; +import { useIntlayer } from "react-intlayer"; import { Helmet } from 'react-helmet-async'; import JobFilterModal from './ui/JobFilterModal'; import useFilterStore from '../store/filterStore'; const JobListing = () => { - const { t, ready } = useTranslation(); + const content = useIntlayer("jobListing"); + const commonContent = useIntlayer("common"); const { filters, setFilters } = useFilterStore(); - // Ждем загрузки переводов - const defaultCity = ready ? t('choose_city_dashboard') : 'Выбрать город'; - const defaultTitle = ready ? t('latest_jobs') : 'Последние вакансии'; + // Use Intlayer content instead of i18next + const defaultCity = content.chooseCityDashboard.value; + const defaultTitle = content.latestJobs.value; const [currentPage, setCurrentPage] = useState(1); const [selectedCity, setSelectedCity] = useState({ value: null, label: defaultCity }); @@ -41,13 +42,13 @@ const JobListing = () => { // Генерация SEO-friendly заголовка const pageTitle = selectedCity.value - ? `${t('jobs_in', { city: selectedCity.label })} - WorkNow` + ? `${content.jobsIn.value.replace('{{city}}', selectedCity.label)} - WorkNow` : `${defaultTitle} | WorkNow`; // Генерация динамического описания страницы const pageDescription = selectedCity.value - ? `${t('find_jobs_in', { city: selectedCity.label })}. ${t('new_vacancies_from_employers')}.` - : `${t('job_search_platform')} - ${t('find_latest_jobs')}.`; + ? `${content.findJobsIn.value.replace('{{city}}', selectedCity.label)}. ${content.newVacanciesFromEmployers.value}.` + : `${content.jobSearchPlatform.value} - ${content.findLatestJobs.value}.`; // Формирование динамического URL для SEO const pageUrl = selectedCity.value @@ -72,7 +73,7 @@ const JobListing = () => { {JSON.stringify({ "@context": "https://schema.org", "@type": "JobPosting", - "title": selectedCity.value ? `Работа в ${selectedCity.label}` : "Работа в Израиле", + "title": selectedCity.value ? content.jobPostingTitle.value.replace('{{city}}', selectedCity.label) : content.jobPostingTitleDefault.value, "description": pageDescription, "datePosted": new Date().toISOString(), "employmentType": "Full-time", @@ -85,7 +86,7 @@ const JobListing = () => { "@type": "Place", "address": { "@type": "PostalAddress", - "addressLocality": selectedCity.value ? selectedCity.label : "Израиль", + "addressLocality": selectedCity.value ? selectedCity.label : content.jobLocationDefault.value, "addressCountry": "IL" } } @@ -107,7 +108,7 @@ const JobListing = () => { onClick={() => setFilterOpen(true)} > - {t('board_settings')} + {content.boardSettings.value}
{ - const { t, i18n } = useTranslation(); + const content = useIntlayer("navbar"); + const { locale } = useLocale(); + const { changeLanguage, isLoading, clearLanguagePreference } = useLanguageManager(); const location = useLocation(); - // Используем отдельные селекторы для language и changeLanguage - const language = useLanguageStore((state) => state.language); - const changeLanguage = useLanguageStore((state) => state.changeLanguage); - const [isExpanded, setIsExpanded] = useState(false); // Close mobile navbar when route changes @@ -35,11 +28,18 @@ const Navbar = () => { setIsExpanded(false); }, [location.pathname]); - // Обработчик смены языка - const handleLanguageChange = (lang) => { - changeLanguage(lang); // Обновляем Zustand хранилище - i18n.changeLanguage(lang); // Обновляем i18n - }; + // Expose functions to window for testing + useEffect(() => { + window.resetLanguageDetection = clearLanguagePreference; + window.testLanguageLoading = () => { + console.log('🧪 Language Loading Test'); + console.log('Current locale:', locale); + console.log('Is loading:', isLoading); + console.log('Available languages:', ['ru', 'en', 'he', 'ar', 'uk']); + console.log('Cookie value:', document.cookie); + console.log('LocalStorage value:', localStorage.getItem('worknow-language')); + }; + }, [clearLanguagePreference, locale, isLoading]); return ( <> @@ -56,19 +56,19 @@ const Navbar = () => {
  • - {t("vacancies")} + {content.vacancies.value}
  • /
  • - {t("seekers")} + {content.seekers.value}
  • /
  • - {t("jobs")} + {content.jobs.value}
  • {/* Dropdown Support */} @@ -81,7 +81,7 @@ const Navbar = () => { data-bs-toggle="dropdown" aria-expanded="false" > - {t("support")} + {content.support.value}
    • @@ -91,17 +91,17 @@ const Navbar = () => { target="_blank" rel="noopener noreferrer" > - {t("rules")} + {content.rules.value}
    • - {t("technical_support")} + {content.technicalSupport.value}
    • - Выставление счетов + {content.billing.value}
    @@ -122,32 +122,52 @@ const Navbar = () => { aria-expanded="false" > - {language === "en" ? "English" : language === "uk" ? "Українська" : language === "he" ? "עברית" : language === "ar" ? "العربية" : "Русский"} + {locale === "en" ? content.languageNames.en.value : locale === "he" ? content.languageNames.he.value : locale === "ar" ? content.languageNames.ar.value : locale === "uk" ? content.languageNames.uk.value : content.languageNames.ru.value}
    • -
    • -
    • -
    • -
    • -
    @@ -158,7 +178,7 @@ const Navbar = () => { - {t("signin")} + {content.signIn.value} @@ -192,17 +212,17 @@ const Navbar = () => {
    • - {t("vacancies")} + {content.vacancies.value}
    • - {t("seekers")} + {content.seekers.value}
    • - {t("jobs")} + {content.jobs.value}
    • {/* Dropdown Support */} @@ -214,7 +234,7 @@ const Navbar = () => { data-bs-toggle="dropdown" aria-expanded="false" > - {t("support")} + {content.support.value}
      • @@ -224,12 +244,12 @@ const Navbar = () => { target="_blank" rel="noopener noreferrer" > - {t("rules")} + {content.rules.value}
      • - {t("technical_support")} + {content.technicalSupport.value}
      @@ -246,23 +266,53 @@ const Navbar = () => { aria-expanded="false" > - {language === "en" ? "English" : language === "uk" ? "Українська" : language === "he" ? "עברית" : language === "ar" ? "العربية" : "Русский"} + {locale === "en" ? content.languageNames.en.value : locale === "he" ? content.languageNames.he.value : locale === "ar" ? content.languageNames.ar.value : locale === "uk" ? content.languageNames.uk.value : content.languageNames.ru.value}
      • - +
      • - +
      • - +
      • - +
      • - +
@@ -270,7 +320,7 @@ const Navbar = () => { - {t("signin")} + {content.signIn.value} diff --git a/apps/client/src/components/PremiumPage.jsx b/apps/client/src/components/PremiumPage.jsx index 490df5f..981b51e 100644 --- a/apps/client/src/components/PremiumPage.jsx +++ b/apps/client/src/components/PremiumPage.jsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { useUser, useClerk } from "@clerk/clerk-react"; import axios from "axios"; import { useUserSync } from "../hooks/useUserSync.js"; -import { useTranslation } from "react-i18next"; +import { useIntlayer } from "react-intlayer"; import { useLoadingProgress } from '../hooks/useLoadingProgress'; import { useGoogleAnalytics } from '../hooks/useGoogleAnalytics.js'; @@ -14,7 +14,7 @@ const PremiumPage = () => { const [loading, setLoading] = useState(false); const { dbUser, loading: userLoading, error: userError, refreshUser } = useUserSync(); const { startLoadingWithProgress, completeLoading } = useLoadingProgress(); - const { t } = useTranslation(); + const content = useIntlayer("premiumPage"); const { trackPremiumSubscription, trackButtonClick, trackError } = useGoogleAnalytics(); // Text carousel state @@ -23,10 +23,10 @@ const PremiumPage = () => { // Different variations of the pricing title const titleVariations = [ - t('pricing_title'), - t('pricing_effective'), - t('pricing_convenient'), - t('pricing_trust') + content.pricingTitle.value, + content.pricingEffective.value, + content.pricingConvenient.value, + content.pricingTrust.value ]; // Text carousel effect - change title every 3 seconds @@ -57,12 +57,12 @@ const PremiumPage = () => { price: 0, period: "/mo", features: [ - { textKey: "pricing_free_up_to_5_ads", icon: "📝", color: "text-primary" }, - { textKey: "pricing_free_daily_boost", icon: "📈", color: "text-success" }, - { textKey: "pricing_free_basic_support", icon: "💬", color: "text-info" }, + { text: content.pricingFreeUpTo5Ads.value, icon: "📝", color: "text-primary" }, + { text: content.pricingFreeDailyBoost.value, icon: "📈", color: "text-success" }, + { text: content.pricingFreeBasicSupport.value, icon: "💬", color: "text-info" }, ], button: { - textKey: "pricing_free_use_free", + text: content.pricingFreeUseFree.value, variant: "outline-primary", action: (navigate) => navigate("/create-new-advertisement") } @@ -72,15 +72,15 @@ const PremiumPage = () => { price: 99, period: "/mo", features: [ - { textKey: "pricing_pro_up_to_10_ads", icon: "📝", color: "text-primary" }, - { textKey: "pricing_pro_unlimited_seeker_data", icon: "👥", color: "text-success" }, - { textKey: "pricing_pro_top_jobs", icon: "⭐", color: "text-warning" }, - { textKey: "pricing_pro_color_highlighting", icon: "🎨", color: "text-info" }, - { textKey: "pricing_pro_advanced_filters", icon: "🔍", color: "text-primary" }, - { textKey: "pricing_pro_priority_support", icon: "🚀", color: "text-danger" }, + { text: content.pricingProUpTo10Ads.value, icon: "📝", color: "text-primary" }, + { text: content.pricingProUnlimitedSeekerData.value, icon: "👥", color: "text-success" }, + { text: content.pricingProTopJobs.value, icon: "⭐", color: "text-warning" }, + { text: content.pricingProColorHighlighting.value, icon: "🎨", color: "text-info" }, + { text: content.pricingProAdvancedFilters.value, icon: "🔍", color: "text-primary" }, + { text: content.pricingProPrioritySupport.value, icon: "🚀", color: "text-danger" }, ], button: { - textKey: "pricing_pro_buy_premium", + text: content.pricingProBuyPremium.value, variant: "primary", priceId: "price_1Qt5J0COLiDbHvw1IQNl90uU" }, @@ -91,17 +91,17 @@ const PremiumPage = () => { price: 200, // Base price for new users period: "/mo", features: [ - { textKey: "pricing_deluxe_all_from_pro", icon: "✨", color: "text-warning" }, - { textKey: "pricing_deluxe_personal_manager", icon: "👨‍💼", color: "text-primary" }, - { textKey: "pricing_deluxe_facebook_autoposting", icon: "⚡", color: "text-info" }, - { textKey: "pricing_deluxe_facebook_promotion", icon: "📢", color: "text-success" }, - { textKey: "pricing_deluxe_personal_mailing", icon: "📧", color: "text-primary" }, - { textKey: "pricing_deluxe_telegram_publications", icon: "🔥", color: "text-info" }, - { textKey: "pricing_deluxe_personal_candidate_selection", icon: "🎯", color: "text-warning" }, - { textKey: "pricing_deluxe_interview_organization", icon: "🤝", color: "text-success" }, + { text: content.pricingDeluxeAllFromPro.value, icon: "✨", color: "text-warning" }, + { text: content.pricingDeluxePersonalManager.value, icon: "👨‍💼", color: "text-primary" }, + { text: content.pricingDeluxeFacebookAutoposting.value, icon: "⚡", color: "text-info" }, + { text: content.pricingDeluxeFacebookPromotion.value, icon: "📢", color: "text-success" }, + { text: content.pricingDeluxePersonalMailing.value, icon: "📧", color: "text-primary" }, + { text: content.pricingDeluxeTelegramPublications.value, icon: "🔥", color: "text-info" }, + { text: content.pricingDeluxePersonalCandidateSelection.value, icon: "🎯", color: "text-warning" }, + { text: content.pricingDeluxeInterviewOrganization.value, icon: "🤝", color: "text-success" }, ], button: { - textKey: "pricing_deluxe_buy_deluxe", + text: content.pricingDeluxeBuyDeluxe.value, variant: "primary", priceId: "price_1RfHjiCOLiDbHvw1repgIbnK" // Price ID for 200 ILS }, @@ -175,11 +175,11 @@ const PremiumPage = () => { }); if (error.response?.status === 404) { - alert("Пользователь не найден. Попробуйте войти заново."); + alert(content.userNotFound.value); } else if (error.response?.data?.error) { alert(`Ошибка оплаты: ${error.response.data.error}`); } else { - alert("Ошибка оплаты. Попробуйте позже."); + alert(content.paymentError.value); } } finally { setLoading(false); @@ -215,7 +215,7 @@ const PremiumPage = () => { margin: '0 auto', padding: '0 20px' }}> - {t('pricing_description')} + {content.pricingDescription.value}

@@ -224,7 +224,7 @@ const PremiumPage = () => { {user && userLoading ? (
- {t('loading')} + {content.loading.value}
) : userError ? ( @@ -238,7 +238,7 @@ const PremiumPage = () => { {getPlans().map((plan) => { let isActive = false; let displayPrice = plan.price; - let buttonText = t(plan.button.textKey); + let buttonText = plan.button.text; let priceId = plan.button.priceId; // Determine plan status based on user subscription @@ -256,12 +256,12 @@ const PremiumPage = () => { if (user && dbUser?.isPremium && !dbUser?.premiumDeluxe) { // User has Pro but not Deluxe - show upgrade price displayPrice = 100; - buttonText = t('pricing_deluxe_upgrade_to_deluxe'); + buttonText = content.pricingDeluxeUpgradeToDeluxe.value; priceId = "price_1Rfli2COLiDbHvw1xdMaguLf"; // Price ID for 100 ILS upgrade } else if (!user || !dbUser?.isPremium) { // New user or no Pro subscription - show full price displayPrice = 200; - buttonText = t(plan.button.textKey); + buttonText = plan.button.text; priceId = plan.button.priceId; } else { // User already has Deluxe or other cases - use default @@ -307,7 +307,7 @@ const PremiumPage = () => { letterSpacing: '0.5px', padding: window.innerWidth <= 768 ? '12px 20px' : '8px 16px' }}> - ⭐ {t('pricing_recommended')} + ⭐ {content.pricingRecommended.value} )} @@ -324,7 +324,7 @@ const PremiumPage = () => { letterSpacing: '0.5px', padding: window.innerWidth <= 768 ? '12px 20px' : '8px 16px' }}> - 🏆 {t('pricing_best_results')} + 🏆 {content.pricingBestResults.value} )} @@ -338,8 +338,7 @@ const PremiumPage = () => { textTransform: 'uppercase', marginTop: plan.name === "Free" ? '50px' : '0' }}> - {plan.name === "Free" ? t('pricing_free_title') : plan.name - } + {plan.name === "Free" ? content.pricingFreeTitle.value : plan.name} {/* Enhanced Price Section */} @@ -421,7 +420,7 @@ const PremiumPage = () => { fontWeight: 500, color: '#495057' }}> - {t(feature.textKey)} + {feature.text} ))} @@ -445,7 +444,7 @@ const PremiumPage = () => { letterSpacing: '0.5px' }} > - {t('pricing_free_use_free')} + {content.pricingFreeUseFree.value} ) : ( ) ) : ( @@ -480,7 +479,7 @@ const PremiumPage = () => { boxShadow: plan.highlight ? '0 6px 20px rgba(13, 110, 253, 0.4)' : 'none' }} > - {isActive ? t('active') : (loading ? t('loading') : buttonText)} + {isActive ? content.active.value : (loading ? content.loading.value : buttonText)} )} diff --git a/apps/client/src/components/SupportPage.jsx b/apps/client/src/components/SupportPage.jsx index 17f0fd4..a7fb9f2 100755 --- a/apps/client/src/components/SupportPage.jsx +++ b/apps/client/src/components/SupportPage.jsx @@ -1,9 +1,9 @@ -import { useTranslation } from "react-i18next"; +import { useIntlayer } from "react-intlayer"; import { Mail, Phone, MessageCircle, Clock, HelpCircle, Users, Shield, Zap } from "lucide-react"; import { useState, useEffect, useRef } from "react"; const SupportPage = () => { - const { t } = useTranslation(); + const content = useIntlayer("supportPage"); const [isModalOpen, setIsModalOpen] = useState(false); const [selectedMethod, setSelectedMethod] = useState(null); const [touchStart, setTouchStart] = useState(null); @@ -17,8 +17,8 @@ const SupportPage = () => { const supportMethods = [ { icon: Mail, - title: t("support_email_title"), - description: t("support_email_description"), + title: content.support_email_title, + description: content.support_email_description, contact: "worknow.notifications@gmail.com", action: "mailto:worknow.notifications@gmail.com", color: "bg-blue-500", @@ -26,9 +26,9 @@ const SupportPage = () => { }, { icon: MessageCircle, - title: t("support_live_chat_title"), - description: t("support_live_chat_description"), - contact: t("support_available_24_7"), + title: content.support_live_chat_title, + description: content.support_live_chat_description, + contact: content.support_available_24_7, action: "#", color: "bg-green-500", hoverColor: "hover:bg-green-600", @@ -36,8 +36,8 @@ const SupportPage = () => { }, { icon: Phone, - title: t("support_phone_title"), - description: t("support_phone_description"), + title: content.support_phone_title, + description: content.support_phone_description, contact: "+972-053-3033332", action: "tel:+972-053-3033332", color: "bg-purple-500", @@ -47,54 +47,54 @@ const SupportPage = () => { const faqItems = [ { - question: t("support_faq_create_job_question"), - answer: t("support_faq_create_job_answer") + question: content.support_faq_create_job_question, + answer: content.support_faq_create_job_answer }, { - question: t("support_faq_premium_question"), - answer: t("support_faq_premium_answer") + question: content.support_faq_premium_question, + answer: content.support_faq_premium_answer }, { - question: t("support_faq_edit_job_question"), - answer: t("support_faq_edit_job_answer") + question: content.support_faq_edit_job_question, + answer: content.support_faq_edit_job_answer }, { - question: t("support_faq_contact_seekers_question"), - answer: t("support_faq_contact_seekers_answer") + question: content.support_faq_contact_seekers_question, + answer: content.support_faq_contact_seekers_answer }, { - question: t("support_faq_job_limits_question"), - answer: t("support_faq_job_limits_answer") + question: content.support_faq_job_limits_question, + answer: content.support_faq_job_limits_answer }, { - question: t("support_faq_payment_question"), - answer: t("support_faq_payment_answer") + question: content.support_faq_payment_question, + answer: content.support_faq_payment_answer }, { - question: t("support_faq_categories_question"), - answer: t("support_faq_categories_answer") + question: content.support_faq_categories_question, + answer: content.support_faq_categories_answer }, { - question: t("support_faq_cities_question"), - answer: t("support_faq_cities_answer") + question: content.support_faq_cities_question, + answer: content.support_faq_cities_answer } ]; const features = [ { icon: Shield, - title: t("support_secure_platform_title"), - description: t("support_secure_platform_description") + title: content.support_secure_platform_title, + description: content.support_secure_platform_description }, { icon: Zap, - title: t("support_fast_reliable_title"), - description: t("support_fast_reliable_description") + title: content.support_fast_reliable_title, + description: content.support_fast_reliable_description }, { icon: Users, - title: t("support_community_driven_title"), - description: t("support_community_driven_description") + title: content.support_community_driven_title, + description: content.support_community_driven_description } ]; @@ -197,10 +197,10 @@ const SupportPage = () => {

- {t("technical_support")} + {content.technical_support}

- {t("support_hero_description")} + {content.support_hero_description}

@@ -210,10 +210,10 @@ const SupportPage = () => {

- {t("support_get_in_touch")} + {content.support_get_in_touch}

- {t("support_get_in_touch_description")} + {content.support_get_in_touch_description}

@@ -244,11 +244,11 @@ const SupportPage = () => { onClick={() => handleContactClick(method)} className={`inline-flex items-center px-6 py-3 rounded-lg ${method.color} ${method.hoverColor} text-white font-medium transition-colors duration-200`} > - {t("support_contact_now")} + {content.support_contact_now} ) : ( - {t("support_coming_soon")} + {content.support_coming_soon} )}
@@ -261,10 +261,10 @@ const SupportPage = () => {

- {t("support_why_choose_title")} + {content.support_why_choose_title}

- {t("support_why_choose_description")} + {content.support_why_choose_description}

@@ -289,10 +289,10 @@ const SupportPage = () => {

- {t("support_faq_title")} + {content.support_faq_title}

- {t("support_faq_description")} + {content.support_faq_description}

@@ -315,13 +315,13 @@ const SupportPage = () => {
- {t("support_hours_title")} + {content.support_hours_title}

- {t("support_hours_weekdays")} + {content.support_hours_weekdays}

- {t("support_hours_weekend")} + {content.support_hours_weekend}

@@ -337,11 +337,11 @@ const SupportPage = () => { >
-
{t("support_modal_title")}
+
{content.support_modal_title}
@@ -361,7 +361,7 @@ const SupportPage = () => {

-

{t("support_modal_contact_info")}

+

{content.support_modal_contact_info}

{selectedMethod.contact}

@@ -373,11 +373,11 @@ const SupportPage = () => { className={`inline-flex items-center px-8 py-4 rounded-lg ${selectedMethod.color} ${selectedMethod.hoverColor} text-white text-lg font-medium transition-colors duration-200 w-full justify-center`} onClick={closeModal} > - {t("support_contact_now")} + {content.support_contact_now} ) : ( - {t("support_coming_soon")} + {content.support_coming_soon} )}
diff --git a/apps/client/src/components/UserJobs.jsx b/apps/client/src/components/UserJobs.jsx index abab7bb..dedbbbd 100755 --- a/apps/client/src/components/UserJobs.jsx +++ b/apps/client/src/components/UserJobs.jsx @@ -6,11 +6,10 @@ import { toast } from "react-hot-toast"; import { useNavigate } from "react-router-dom"; import { Trash, PencilSquare, SortUp } from "react-bootstrap-icons"; import Skeleton from "react-loading-skeleton"; -import { useTranslation } from "react-i18next"; +import { useIntlayer, useLocale } from "react-intlayer"; import { format } from "date-fns"; import { ru, enUS, he, ar } from "date-fns/locale"; import "react-loading-skeleton/dist/skeleton.css"; -import useLanguageStore from '../store/languageStore'; import { useLoadingProgress } from '../hooks/useLoadingProgress'; import { useTranslationHelpers } from '../utils/translationHelpers'; import { ImageModal } from './ui'; @@ -19,11 +18,11 @@ import PaginationControl from './PaginationControl'; const API_URL = import.meta.env.VITE_API_URL; // Берем API из .env const UserJobs = () => { - const { t } = useTranslation(); + const content = useIntlayer("userJobs"); + const { locale } = useLocale(); const { user } = useUser(); const { getToken } = useAuth(); const navigate = useNavigate(); - const language = useLanguageStore((state) => state.language) || 'ru'; const { startLoadingWithProgress, completeLoading } = useLoadingProgress(); const { getCityLabel } = useTranslationHelpers(); @@ -42,7 +41,7 @@ const UserJobs = () => { // Helper function to get the appropriate date-fns locale const getDateLocale = () => { - switch (language) { + switch (locale) { case 'en': return enUS; case 'he': @@ -98,7 +97,7 @@ const UserJobs = () => { try { const response = await axios.get( - `${API_URL}/api/users/user-jobs/${user.id}?page=${currentPage}&limit=5&lang=${language}` + `${API_URL}/api/users/user-jobs/${user.id}?page=${currentPage}&limit=5&lang=${locale}` ); // Jobs data received from server @@ -121,7 +120,7 @@ const UserJobs = () => { "❌ Ошибка загрузки объявлений пользователя:", error.response?.data || error.message ); - toast.error("Ошибка загрузки ваших объявлений!"); + toast.error(content.loadJobsError.value); completeLoading(); // Complete loading even on error } finally { setLoading(false); @@ -130,7 +129,7 @@ const UserJobs = () => { useEffect(() => { fetchUserJobs(); - }, [user, currentPage, language]); // Loading functions are stable now + }, [user, currentPage, locale]); // Loading functions are stable now const handleDelete = async () => { if (!jobToDelete) return; @@ -145,12 +144,12 @@ const UserJobs = () => { } }); completeLoading(); // Complete loading when done - toast.success("Объявление удалено!"); + toast.success(content.jobDeletedSuccess.value); setJobs((prevJobs) => prevJobs.filter((job) => job.id !== jobToDelete)); } catch (error) { console.error("Ошибка удаления объявления:", error); completeLoading(); // Complete loading even on error - toast.error("Ошибка удаления объявления!"); + toast.error(content.jobDeletedError.value); } finally { setShowModal(false); setJobToDelete(null); @@ -177,11 +176,11 @@ const UserJobs = () => { } }); completeLoading(); // Complete loading when done - toast.success("Объявление поднято в топ!"); + toast.success(content.jobBoostedSuccess.value); fetchUserJobs(); } catch (error) { completeLoading(); // Complete loading even on error - toast.error(error.response?.data?.error || "Ошибка поднятия объявления"); + toast.error(error.response?.data?.error || content.jobBoostedError.value); } }; @@ -266,14 +265,14 @@ const UserJobs = () => { }, [imageLoadingStates]); if (!user) { - return

{t("sing_in_to_view")}

; + return

{content.signInToView.value}

; } return ( <>
-

- {loading ? : t("my_ads_title")} +

+ {loading ? : content.myAdsTitle.value}

{loading ? ( @@ -293,8 +292,8 @@ const UserJobs = () => { ))}

) : jobs.length === 0 ? ( -
-

{t("you_dont_have_ads")}

+
+

{content.youDontHaveAds.value}

{ {/* Плашка Премиум */} {job.user?.isPremium && (
- {t('premium_badge')} + {content.premiumBadge.value}
)} -
+
{job.title}
{job.category?.label && (
@@ -348,35 +347,35 @@ const UserJobs = () => { )} {!job.category?.label && (
- {t('not_specified') || 'Не указано'} + {content.notSpecified.value}
)}

- {t("salary_per_hour_card")} {job.salary} + {content.salaryPerHourCard.value} {job.salary}
- {t("location_card")}{" "} - {job.city?.name ? getCityLabel(job.city.name) : t("not_specified")} + {content.locationCard.value}{" "} + {job.city?.name ? getCityLabel(job.city.name) : content.notSpecified.value}

{job.description}

{typeof job.shuttle === 'boolean' && (

- {t("shuttle") || "Подвозка"}: {job.shuttle ? t("yes") || "да" : t("no") || "нет"} + {content.shuttle.value}: {job.shuttle ? content.yes.value : content.no.value}

)} {typeof job.meals === 'boolean' && (

- {t("meals") || "Питание"}: {job.meals ? t("yes") || "да" : t("no") || "нет"} + {content.meals.value}: {job.meals ? content.yes.value : content.no.value}

)}

- {t("phone_number_card")} {job.phone} + {content.phoneNumberCard.value} {job.phone}

{/* Image displayed under phone number in mini size */} {job.imageUrl && ( -
+
{/* Image rendering for job */} {imageLoadingStates[job.id] && ( { height={80} style={{ borderRadius: '6px', - border: '1px solid #e0e0e0' + border: '1px solid #e0e0e0', + marginLeft: locale === 'he' || locale === 'ar' ? 'auto' : '0', + marginRight: locale === 'he' || locale === 'ar' ? '0' : 'auto' }} /> )} @@ -403,10 +404,12 @@ const UserJobs = () => { color: '#6c757d', fontSize: '12px', textAlign: 'center', - whiteSpace: 'pre-line' + whiteSpace: 'pre-line', + marginLeft: locale === 'he' || locale === 'ar' ? 'auto' : '0', + marginRight: locale === 'he' || locale === 'ar' ? '0' : 'auto' }} > - {t('image_unavailable')} + {content.imageUnavailable.value}
) : ( { borderRadius: '6px', border: '1px solid #e0e0e0', cursor: 'pointer', - display: imageLoadingStates[job.id] ? 'none' : 'block' + display: imageLoadingStates[job.id] ? 'none' : 'block', + marginLeft: locale === 'he' || locale === 'ar' ? 'auto' : '0', + marginRight: locale === 'he' || locale === 'ar' ? '0' : 'auto' }} onClick={(e) => handleImageClick(e, job.imageUrl, job.title)} onLoad={() => handleImageLoad(job.id)} @@ -437,18 +442,18 @@ const UserJobs = () => {
- {t("created_at") + ": "} + {content.createdAt.value + ": "} {formatDate(job.createdAt)}
-
+
{isBoosted ? ( -
+
- {t("next_boost_after")} + {content.nextBoostAfter.value}
{ whiteSpace: 'nowrap' }} > - {timeUntilNextBoost ? `${timeUntilNextBoost.hours}${t('hours_short')} ${timeUntilNextBoost.minutes}${t('minutes_short')}` : t('boost_ready')} + {timeUntilNextBoost ? `${timeUntilNextBoost.hours}${content.hoursShort.value} ${timeUntilNextBoost.minutes}${content.minutesShort.value}` : content.boostReady.value}
) : ( @@ -469,7 +474,7 @@ const UserJobs = () => { size={24} className="text-success" onClick={() => handleBoost(job.id)} - title={t('boost_title')} + title={content.boostTitle.value} style={{ cursor: 'pointer' }} /> )} @@ -503,15 +508,15 @@ const UserJobs = () => { {/* Delete Confirmation Modal */} setShowModal(false)} centered> - {t("confirm_delete")} + {content.confirmDelete.value} - {t("confirm_delete_text")} + {content.confirmDeleteText.value} diff --git a/apps/client/src/components/UserProfile.jsx b/apps/client/src/components/UserProfile.jsx index 7e128c1..222d273 100755 --- a/apps/client/src/components/UserProfile.jsx +++ b/apps/client/src/components/UserProfile.jsx @@ -4,7 +4,7 @@ import { useParams } from "react-router-dom"; import axios from "axios"; import Skeleton from "react-loading-skeleton"; import { Helmet } from "react-helmet-async"; -import { useTranslation } from "react-i18next"; +import { useIntlayer } from "react-intlayer"; import "bootstrap/dist/css/bootstrap.min.css"; import "react-loading-skeleton/dist/skeleton.css"; import { useUser } from '@clerk/clerk-react'; @@ -15,7 +15,7 @@ import PaginationControl from "./PaginationControl"; const API_URL = import.meta.env.VITE_API_URL; const UserProfile = () => { - const { t } = useTranslation(); + const content = useIntlayer("userProfile"); const { user: clerkUser, isLoaded } = useUser(); const [user, setUser] = useState(null); const [jobs, setJobs] = useState([]); @@ -107,12 +107,12 @@ const UserProfile = () => { : user; const pageTitle = profileData - ? `${profileData.name || ''} | ${t("user_profile_title")} - WorkNow` - : `${t("user_not_found") } | WorkNow`; + ? `${profileData.name || ''} | ${content.user_profile_title} - WorkNow` + : `${content.user_not_found} | WorkNow`; const pageDescription = profileData - ? `${t("profile_description", { name: profileData.name })}. ${t("user_jobs")}: ${jobs.length}.` - : t("user_profile_not_found_description"); + ? `${content.profile_description}: ${profileData.name}. ${content.user_jobs}: ${jobs.length}.` + : content.user_profile_not_found_description; const profileImage = profileData?.imageUrl || "/images/default-avatar.png"; const profileUrl = `https://worknow.co.il/user/${clerkUserId}`; @@ -177,12 +177,12 @@ const UserProfile = () => { {loading ? ( ) : !profileData ? ( -

{t("user_not_found")}

+

{content.user_not_found}

) : ( <> -

{t("user_jobs")}

+

{content.user_jobs}

{jobs.length === 0 ? ( -

{t("user_no_jobs")}

+

{content.user_no_jobs}

) : ( <> {jobs.map((job) => ( diff --git a/apps/client/src/components/form/AddSeekerModal.jsx b/apps/client/src/components/form/AddSeekerModal.jsx index 7db5b63..049beee 100644 --- a/apps/client/src/components/form/AddSeekerModal.jsx +++ b/apps/client/src/components/form/AddSeekerModal.jsx @@ -1,11 +1,11 @@ import { useState } from "react"; -import { useTranslation } from 'react-i18next'; +import { useIntlayer } from 'react-intlayer'; import useFetchCities from '../../hooks/useFetchCities'; import useFetchCategories from '../../hooks/useFetchCategories'; import PropTypes from 'prop-types'; export default function AddSeekerModal({ show, onClose, onSubmit }) { - const { t } = useTranslation(); + const content = useIntlayer("addSeekerModal"); const [step, setStep] = useState(1); const [form, setForm] = useState({ name: '', @@ -27,26 +27,26 @@ export default function AddSeekerModal({ show, onClose, onSubmit }) { const [error, setError] = useState(null); const languageOptions = [ - { value: 'русский', label: t('language_russian') || 'Русский' }, - { value: 'арабский', label: t('language_arabic') || 'Арабский' }, - { value: 'английский', label: t('language_english') || 'Английский' }, - { value: 'иврит', label: t('language_hebrew') || 'Иврит' }, + { value: 'русский', label: content.languageRussian.value }, + { value: 'арабский', label: content.languageArabic.value }, + { value: 'английский', label: content.languageEnglish.value }, + { value: 'иврит', label: content.languageHebrew.value }, ]; const { cities, loading: loadingCities } = useFetchCities(); const { categories, loading: loadingCategories } = useFetchCategories(); const employmentOptions = [ - { value: 'полная', label: t('employment_full') || 'Полная' }, - { value: 'частичная', label: t('employment_partial') || 'Частичная' }, + { value: 'полная', label: content.employmentFull.value }, + { value: 'частичная', label: content.employmentPartial.value }, ]; const documentTypeOptions = [ - { value: 'Виза Б1', label: t('document_visa_b1') || 'Виза Б1' }, - { value: 'Виза Б2', label: t('document_visa_b2') || 'Виза Б2' }, - { value: 'Теудат Зеут', label: t('document_teudat_zehut') || 'Теудат Зеут' }, - { value: 'Рабочая виза', label: t('document_work_visa') || 'Рабочая виза' }, - { value: 'Другое', label: t('document_other') || 'Другое' }, + { value: 'Виза Б1', label: content.documentVisaB1.value }, + { value: 'Виза Б2', label: content.documentVisaB2.value }, + { value: 'Теудат Зеут', label: content.documentTeudatZehut.value }, + { value: 'Рабочая виза', label: content.documentWorkVisa.value }, + { value: 'Другое', label: content.documentOther.value }, ]; if (!show) return null; @@ -54,7 +54,7 @@ export default function AddSeekerModal({ show, onClose, onSubmit }) { const handleNext = (e) => { e.preventDefault(); if (!form.name || !form.contact || !form.city || !form.description || !form.gender) { - setError(t('fill_all_fields_error')); + setError(content.fillAllFieldsError.value); return; } setError(null); @@ -93,7 +93,7 @@ export default function AddSeekerModal({ show, onClose, onSubmit }) { const handleSubmit = (e) => { e.preventDefault(); if (!form.nativeLanguage || form.languages.length === 0) { - setError(t('language_selection_error')); + setError(content.languageSelectionError.value); return; } onSubmit(form); @@ -133,7 +133,7 @@ export default function AddSeekerModal({ show, onClose, onSubmit }) { color: '#495057', margin: 0 }}> - {t('add_seeker') || 'Добавить соискателя'} + {content.addSeeker.value}
МесяцСуммаТип подпискиДата{content.month}{content.amount}{content.subscription_type}{content.date}