diff --git a/client/package.json b/client/package.json index c8879ef..d671719 100644 --- a/client/package.json +++ b/client/package.json @@ -22,10 +22,13 @@ "@types/react-dom": "^18.3.0", "axios": "^1.7.7", "dayjs": "^1.11.13", + "i18next": "^25.6.0", + "i18next-browser-languagedetector": "^8.2.0", "lodash": "^4.17.21", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hot-toast": "^2.4.1", + "react-i18next": "^16.0.1", "react-router-dom": "^6.26.2", "web-vitals": "^2.1.4" }, diff --git a/client/src/i18n.ts b/client/src/i18n.ts new file mode 100644 index 0000000..0b98a4f --- /dev/null +++ b/client/src/i18n.ts @@ -0,0 +1,28 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; + +import en from './locales/en/translation.json'; +import hi from './locales/hi/translation.json'; + +// i18n instance is initialized once for the whole app +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + en: { translation: en }, + hi: { translation: hi }, + }, + fallbackLng: 'en', + supportedLngs: ['en', 'hi'], + interpolation: { escapeValue: false }, + detection: { + order: ['querystring', 'localStorage', 'navigator', 'htmlTag'], + caches: ['localStorage'], + }, + }); + +export default i18n; + + diff --git a/client/src/index.tsx b/client/src/index.tsx index f3a62e3..f65401a 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -4,6 +4,7 @@ import { BrowserRouter } from 'react-router-dom'; import { StyledEngineProvider } from '@mui/material/styles'; import { secrets } from './config/secrets'; import './styles.css'; +import './i18n'; import { PreferencesProvider } from './context/PreferencesContext'; import { ApiProvider } from '@/context/ApiContext'; import { LocalizationProvider } from '@mui/x-date-pickers'; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json new file mode 100644 index 0000000..656d293 --- /dev/null +++ b/client/src/locales/en/translation.json @@ -0,0 +1,22 @@ +{ + "nav": { + "preferences": "Preferences", + "support": "Support", + "logout": "Logout" + }, + "preferences": { + "noPreference": "No preference", + "selectFloor": "Select preferred floor", + "selectDuration": "Select preferred meeting duration", + "selectCapacity": "Select preferred room capacity", + "titlePlaceholder": "Add preferred title", + "save": "Save", + "saved": "Saved successfully!" + }, + "home": { + "newEvent": "New Event", + "myEvents": "My Events" + } +} + + diff --git a/client/src/locales/hi/translation.json b/client/src/locales/hi/translation.json new file mode 100644 index 0000000..b1b4ed2 --- /dev/null +++ b/client/src/locales/hi/translation.json @@ -0,0 +1,22 @@ +{ + "nav": { + "preferences": "पसंद", + "support": "सहायता", + "logout": "लॉग आउट" + }, + "preferences": { + "noPreference": "कोई प्राथमिकता नहीं", + "selectFloor": "पसंदीदा मंज़िल चुनें", + "selectDuration": "पसंदीदा मीटिंग अवधि चुनें", + "selectCapacity": "पसंदीदा कक्ष क्षमता चुनें", + "titlePlaceholder": "पसंदीदा शीर्षक जोड़ें", + "save": "सहेजें", + "saved": "सफलतापूर्वक सहेजा गया!" + }, + "home": { + "newEvent": "नया इवेंट", + "myEvents": "मेरे इवेंट" + } +} + + diff --git a/client/src/pages/Home/TopNavigationBar.tsx b/client/src/pages/Home/TopNavigationBar.tsx index 3af06d1..c51d1f5 100644 --- a/client/src/pages/Home/TopNavigationBar.tsx +++ b/client/src/pages/Home/TopNavigationBar.tsx @@ -2,6 +2,7 @@ import { Box, IconButton, styled, SxProps, Theme, ToggleButton, ToggleButtonGrou import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded'; import { useNavigate } from 'react-router-dom'; import { ROUTES } from '@/config/routes'; +import { useTranslation } from 'react-i18next'; const TopBar = styled(Box)(({ theme }) => ({ paddingTop: theme.spacing(1.5), @@ -55,6 +56,7 @@ interface TopNavigationBarProps { const TopNavigationBar = ({ sx, tabIndex, handleTabChange }: TopNavigationBarProps) => { const navigate = useNavigate(); + const { t } = useTranslation(); const handleChange = (_: React.SyntheticEvent | null, newValue: number) => { if (newValue !== null) { @@ -83,10 +85,10 @@ const TopNavigationBar = ({ sx, tabIndex, handleTabChange }: TopNavigationBarPro > - New Event + {t('home.newEvent', 'New Event')} - My Events + {t('home.myEvents', 'My Events')} diff --git a/client/src/pages/Settings/PreferenceView.tsx b/client/src/pages/Settings/PreferenceView.tsx index eb02f7a..9d82c07 100644 --- a/client/src/pages/Settings/PreferenceView.tsx +++ b/client/src/pages/Settings/PreferenceView.tsx @@ -5,6 +5,7 @@ import { usePreferences } from '@/context/PreferencesContext'; import { createDropdownOptions, isChromeExt, populateDurationOptions, populateRoomCapacity, renderError } from '@/helpers/utility'; import { Box, Button, Typography } from '@mui/material'; import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import toast from 'react-hot-toast'; import { useNavigate } from 'react-router-dom'; import EventSeatRoundedIcon from '@mui/icons-material/EventSeatRounded'; @@ -14,6 +15,7 @@ import TitleIcon from '@mui/icons-material/Title'; import { useApi } from '@/context/ApiContext'; export default function PreferenceView() { + const { t } = useTranslation(); // Form state const [formData, setFormData] = useState({ floor: '', @@ -40,7 +42,7 @@ export default function PreferenceView() { useEffect(() => { const init = (floors: string[], capacities: string[]) => { const floorOptions = createDropdownOptions(floors); - floorOptions.unshift({ text: 'No preference', value: '' }); + floorOptions.unshift({ text: t('preferences.noPreference'), value: '' }); setFloorOptions(floorOptions); setRoomCapacityOptions(createDropdownOptions(capacities)); @@ -95,7 +97,7 @@ export default function PreferenceView() { duration: Number(formData.duration), }); - toast.success('Saved successfully!'); + toast.success(t('preferences.saved')); }; return ( @@ -127,7 +129,7 @@ export default function PreferenceView() { sx={{ borderTopLeftRadius: 10, borderTopRightRadius: 10, height: '60px' }} id="floor" value={formData.floor} - placeholder={'Select preferred floor'} + placeholder={t('preferences.selectFloor')} options={floorOptions} onChange={handleInputChange} icon={ @@ -147,7 +149,7 @@ export default function PreferenceView() { value={formData.duration} options={durationOptions} onChange={handleInputChange} - placeholder={'Select preferred meeting duration'} + placeholder={t('preferences.selectDuration')} icon={ } id="title" - placeholder="Add preferred title" + placeholder={t('preferences.titlePlaceholder')} onChange={handleInputChange} /> @@ -222,7 +224,7 @@ export default function PreferenceView() { ]} > - Save + {t('preferences.save')} diff --git a/client/src/pages/Settings/index.tsx b/client/src/pages/Settings/index.tsx index 034f33f..b93ae46 100644 --- a/client/src/pages/Settings/index.tsx +++ b/client/src/pages/Settings/index.tsx @@ -8,9 +8,13 @@ import { useState } from 'react'; import { chromeBackground, isChromeExt } from '@helpers/utility'; import { styled, ToggleButton, ToggleButtonGroup } from '@mui/material'; import ExitToAppRoundedIcon from '@mui/icons-material/ExitToAppRounded'; +import LanguageIcon from '@mui/icons-material/Language'; +import { MenuItem, Select } from '@mui/material'; +import i18n from '@/i18n'; import PreferenceView from '@/pages/Settings/PreferenceView'; import SupportView from '@/pages/Settings/SupportView'; import LogoutView from '@/pages/Settings/LogoutView'; +import { useTranslation } from 'react-i18next'; const TopBar = styled(Box)(({ theme }) => ({ paddingTop: theme.spacing(1.5), @@ -57,6 +61,8 @@ const StyledToggleButton = styled(ToggleButton)(({ theme }) => ({ export default function Settings() { const [tabIndex, setTabIndex] = useState(0); const navigate = useNavigate(); + const { t } = useTranslation(); + const [language, setLanguage] = useState(i18n.language || 'en'); const handleTabChange = (_: React.SyntheticEvent | null, newValue: number) => { if (newValue !== null) { @@ -68,6 +74,12 @@ export default function Settings() { navigate(ROUTES.home); }; + const handleLanguageChange = (event: any) => { + const newLang = event.target.value; + setLanguage(newLang); + i18n.changeLanguage(newLang); + }; + if (!open) { return <>; } @@ -119,28 +131,41 @@ export default function Settings() { > - Preferences + {t('nav.preferences')} - Support + {t('nav.support')} - {/* logout icon */} + {/* language + logout */} ({ borderRadius: 100, - backgroundColor: tabIndex === 2 ? theme.palette.grey[100] : 'white', - p: 1, + backgroundColor: 'white', + px: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', textAlign: 'center', + gap: 1, ml: 0, }), ]} > + + handleTabChange(null, 2)}> =6.9.0" } @@ -6463,6 +6464,15 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -6531,6 +6541,46 @@ "ms": "^2.0.0" } }, + "node_modules/i18next": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.6.0.tgz", + "integrity": "sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw==", + "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.27.6" + }, + "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/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -9013,6 +9063,32 @@ "react-dom": ">=16" } }, + "node_modules/react-i18next": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.0.1.tgz", + "integrity": "sha512-0S//bpYEkCPjzuVmxDf9Z6+Y+ArNvpAUk7eDL4qNCZXjDh6Z9j6MZ+NThU7kMCOsmYmDCun3GYEwkiOjjZo9Ug==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.27.6", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 25.5.2", + "react": ">= 16.8.0", + "typescript": "^5" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "19.0.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.0.0.tgz", @@ -9145,11 +9221,6 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==" }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" - }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -10610,7 +10681,7 @@ "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10820,6 +10891,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/watchpack": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz",