feat: Add comprehensive internationalization (i18n) support

- Add multi-language support with i18next and react-i18next
- Implement user-selectable date format preferences (5 formats)
- Add time format preferences (12-hour/24-hour)
- Implement RTL (right-to-left) support for Arabic, Hebrew, Persian, Urdu
- Add first day of week preference
- Create LocalizationContext for managing i18n settings
- Add LocalizationSettings component to Settings page
- Include sample translations: English, Spanish, Arabic
- Configure 10 languages: en, es, fr, de, ar, he, zh, ja, pt, ru
- Add translation management documentation and Crowdin config
- Create date formatting utilities that respect user preferences
- Add RTL CSS styles for proper layout mirroring
- Update Settings and ThemeToggle components to use translations
- Add comprehensive documentation (implementation guide, quick reference, translation guide)

All user preferences are persisted to localStorage and apply throughout the app.
This commit is contained in:
Mo Tarbin
2026-03-08 15:57:57 +00:00
parent 21e0b08716
commit 6d6185dc5e
26 changed files with 2234 additions and 20 deletions

View File

@@ -1,5 +1,6 @@
import { AlertsProvider } from '../service/AlertsProvider'
import { NotificationProvider } from '../service/NotificationProvider'
import { LocalizationProvider } from './LocalizationContext'
import QueryContext from './QueryContext'
import RouterContext from './RouterContext'
import ThemeContext from './ThemeContext'
@@ -8,6 +9,7 @@ const Contexts = ({ children }) => {
const contexts = [
AlertsProvider,
ThemeContext,
LocalizationProvider,
QueryContext,
NotificationProvider,
RouterContext,

View File

@@ -0,0 +1,119 @@
import useStickyState from '@/hooks/useStickyState'
import moment from 'moment'
import { createContext, useContext, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
const LocalizationContext = createContext()
export const DATE_FORMATS = {
MDY: 'MM/DD/YYYY',
DMY: 'DD/MM/YYYY',
YMD: 'YYYY-MM-DD',
LONG: 'MMMM D, YYYY',
SHORT: 'MMM D, YYYY',
}
export const TIME_FORMATS = {
HOUR_12: 'h:mm A',
HOUR_24: 'HH:mm',
}
export const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur']
export const AVAILABLE_LANGUAGES = [
{ code: 'en', name: 'English', nativeName: 'English' },
{ code: 'es', name: 'Spanish', nativeName: 'Español' },
{ code: 'fr', name: 'French', nativeName: 'Français' },
{ code: 'de', name: 'German', nativeName: 'Deutsch' },
{ code: 'ar', name: 'Arabic', nativeName: 'العربية' },
{ code: 'he', name: 'Hebrew', nativeName: 'עברית' },
{ code: 'zh', name: 'Chinese', nativeName: '中文' },
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
{ code: 'pt', name: 'Portuguese', nativeName: 'Português' },
{ code: 'ru', name: 'Russian', nativeName: 'Русский' },
]
export const LocalizationProvider = ({ children }) => {
const { i18n } = useTranslation()
const [dateFormat, setDateFormat] = useStickyState(
DATE_FORMATS.MDY,
'dateFormat',
)
const [timeFormat, setTimeFormat] = useStickyState(
TIME_FORMATS.HOUR_12,
'timeFormat',
)
const [firstDayOfWeek, setFirstDayOfWeek] = useStickyState(0, 'firstDayOfWeek') // 0 = Sunday, 1 = Monday
const [language, setLanguage] = useStickyState('en', 'language')
useEffect(() => {
i18n.changeLanguage(language)
moment.locale(language)
}, [language, i18n])
useEffect(() => {
const isRTL = RTL_LANGUAGES.includes(language)
document.documentElement.dir = isRTL ? 'rtl' : 'ltr'
document.documentElement.lang = language
}, [language])
const formatDate = (date, format = dateFormat) => {
if (!date) return ''
return moment(date).format(format)
}
const formatDateTime = (date, format) => {
if (!date) return ''
const dateTimeFormat = format || `${dateFormat} ${timeFormat}`
return moment(date).format(dateTimeFormat)
}
const formatTime = (date, format = timeFormat) => {
if (!date) return ''
return moment(date).format(format)
}
const formatRelative = date => {
if (!date) return ''
return moment(date).fromNow()
}
const formatCalendar = date => {
if (!date) return ''
return moment(date).calendar()
}
const isRTL = RTL_LANGUAGES.includes(language)
const value = {
dateFormat,
setDateFormat,
timeFormat,
setTimeFormat,
firstDayOfWeek,
setFirstDayOfWeek,
language,
setLanguage,
isRTL,
formatDate,
formatDateTime,
formatTime,
formatRelative,
formatCalendar,
availableLanguages: AVAILABLE_LANGUAGES,
}
return (
<LocalizationContext.Provider value={value}>
{children}
</LocalizationContext.Provider>
)
}
export const useLocalization = () => {
const context = useContext(LocalizationContext)
if (!context) {
throw new Error('useLocalization must be used within LocalizationProvider')
}
return context
}

117
src/i18n/README.md Normal file
View File

@@ -0,0 +1,117 @@
# Internationalization (i18n) Setup
This directory contains the internationalization configuration for Donetick.
## Structure
```
src/i18n/
├── config.js # i18next configuration
└── README.md # This file
public/locales/
├── en/ # English (default)
│ ├── common.json
│ ├── settings.json
│ └── chores.json
├── es/ # Spanish
├── ar/ # Arabic (RTL)
└── ...
```
## Usage in Components
### Using translations
```jsx
import { useTranslation } from 'react-i18next'
function MyComponent() {
const { t } = useTranslation('settings') // or 'common', 'chores'
return <h1>{t('title')}</h1>
}
```
### Using date formatting
```jsx
import { useLocalization } from '@/contexts/LocalizationContext'
function MyComponent() {
const { formatDate, formatDateTime, formatRelative } = useLocalization()
const date = new Date()
return (
<div>
<p>Date: {formatDate(date)}</p>
<p>DateTime: {formatDateTime(date)}</p>
<p>Relative: {formatRelative(date)}</p>
</div>
)
}
```
### Using language/format settings
```jsx
import { useLocalization } from '@/contexts/LocalizationContext'
function MyComponent() {
const {
language,
setLanguage,
dateFormat,
setDateFormat,
isRTL
} = useLocalization()
return (
<div dir={isRTL ? 'rtl' : 'ltr'}>
Current language: {language}
</div>
)
}
```
## Available Namespaces
- **common**: General UI elements (buttons, messages, etc.)
- **settings**: Settings page translations
- **chores**: Chores-related translations
## Adding New Translations
1. Add the text to the appropriate JSON file in `public/locales/en/`
2. Use the translation in your component with `t('key')`
3. Upload to translation platform for community translation
## RTL Support
Languages in the `RTL_LANGUAGES` array automatically get:
- `dir="rtl"` on the document
- RTL-specific CSS styles
- Proper text alignment
Currently supported RTL languages: Arabic (ar), Hebrew (he), Persian (fa), Urdu (ur)
## Date Format Preferences
Users can choose from:
- MM/DD/YYYY (US)
- DD/MM/YYYY (Europe)
- YYYY-MM-DD (ISO)
- Long format (January 1, 2024)
- Short format (Jan 1, 2024)
## Time Format Preferences
- 12-hour (with AM/PM)
- 24-hour
## First Day of Week
Users can choose:
- Sunday
- Monday

36
src/i18n/config.js Normal file
View File

@@ -0,0 +1,36 @@
import i18n from 'i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import HttpBackend from 'i18next-http-backend'
import { initReactI18next } from 'react-i18next'
i18n
.use(HttpBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
debug: import.meta.env.DEV,
interpolation: {
escapeValue: false,
},
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
ns: ['common', 'settings', 'chores'],
defaultNS: 'common',
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
lookupLocalStorage: 'i18nextLng',
},
react: {
useSuspense: true,
},
})
export default i18n

View File

@@ -45,3 +45,44 @@ html {
.animate-optimized.animation-complete {
will-change: auto;
}
/* RTL Support */
[dir='rtl'] {
direction: rtl;
text-align: right;
}
[dir='rtl'] .rtl-mirror {
transform: scaleX(-1);
}
/* Handle margins and paddings for RTL */
[dir='rtl'] .ml-auto {
margin-left: 0;
margin-right: auto;
}
[dir='rtl'] .mr-auto {
margin-right: 0;
margin-left: auto;
}
/* Flip icons and arrows in RTL */
[dir='rtl'] .rtl-flip {
transform: scaleX(-1);
}
/* Ensure proper text alignment in RTL */
[dir='rtl'] input,
[dir='rtl'] textarea {
text-align: right;
}
/* Handle border radius for RTL */
[dir='rtl'] .rounded-l-none {
border-radius: 0 0.375rem 0.375rem 0;
}
[dir='rtl'] .rounded-r-none {
border-radius: 0.375rem 0 0 0.375rem;
}

View File

@@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import Contexts from './contexts/Contexts.jsx'
import './i18n/config'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(

View File

@@ -0,0 +1,83 @@
import moment from 'moment'
export const createDateFormatter = (
dateFormat,
timeFormat,
firstDayOfWeek,
) => {
moment.updateLocale('en', {
week: {
dow: firstDayOfWeek,
},
})
return {
formatDate: (date, customFormat) => {
if (!date) return ''
return moment(date).format(customFormat || dateFormat)
},
formatDateTime: (date, customFormat) => {
if (!date) return ''
const format = customFormat || `${dateFormat} ${timeFormat}`
return moment(date).format(format)
},
formatTime: (date, customFormat) => {
if (!date) return ''
return moment(date).format(customFormat || timeFormat)
},
formatRelative: date => {
if (!date) return ''
return moment(date).fromNow()
},
formatCalendar: (date, opts) => {
if (!date) return ''
return moment(date).calendar(null, opts)
},
formatShortDate: date => {
if (!date) return ''
return moment(date).format('MMM D, YYYY')
},
formatLongDate: date => {
if (!date) return ''
return moment(date).format('MMMM D, YYYY')
},
isBefore: (date, compareDate) => {
return moment(date).isBefore(compareDate)
},
isAfter: (date, compareDate) => {
return moment(date).isAfter(compareDate)
},
diff: (date1, date2, unit) => {
return moment(date1).diff(moment(date2), unit)
},
add: (date, amount, unit) => {
return moment(date).add(amount, unit).toDate()
},
subtract: (date, amount, unit) => {
return moment(date).subtract(amount, unit).toDate()
},
}
}
export const useDateFormatter = () => {
if (typeof window === 'undefined') {
return createDateFormatter('MM/DD/YYYY', 'h:mm A', 0)
}
const dateFormat = localStorage.getItem('dateFormat') || 'MM/DD/YYYY'
const timeFormat = localStorage.getItem('timeFormat') || 'h:mm A'
const firstDayOfWeek = parseInt(localStorage.getItem('firstDayOfWeek') || '0')
return createDateFormatter(dateFormat, timeFormat, firstDayOfWeek)
}

View File

@@ -0,0 +1,187 @@
import {
useLocalization,
DATE_FORMATS,
TIME_FORMATS,
} from '@/contexts/LocalizationContext'
import { LanguageOutlined } from '@mui/icons-material'
import {
Box,
Button,
Card,
Chip,
Divider,
FormControl,
FormHelperText,
FormLabel,
Option,
Select,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useTranslation } from 'react-i18next'
const LocalizationSettings = () => {
const { t } = useTranslation('settings')
const {
language,
setLanguage,
dateFormat,
setDateFormat,
timeFormat,
setTimeFormat,
firstDayOfWeek,
setFirstDayOfWeek,
availableLanguages,
isRTL,
} = useLocalization()
const sampleDate = moment('2024-01-15 14:30:00')
const dateFormatOptions = [
{ value: DATE_FORMATS.MDY, label: t('localization.formats.mdy') },
{ value: DATE_FORMATS.DMY, label: t('localization.formats.dmy') },
{ value: DATE_FORMATS.YMD, label: t('localization.formats.ymd') },
{ value: DATE_FORMATS.LONG, label: t('localization.formats.long') },
{ value: DATE_FORMATS.SHORT, label: t('localization.formats.short') },
]
return (
<Box>
<Card variant='outlined' sx={{ p: 3, mb: 2 }}>
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<LanguageOutlined />
<Typography level='title-md'>
{t('localization.language')}
</Typography>
</Box>
<Typography level='body-sm' sx={{ mb: 2 }}>
{t('localization.languageDescription')}
</Typography>
<FormControl>
<Select
value={language}
onChange={(_, value) => setLanguage(value)}
sx={{ minWidth: 250 }}
>
{availableLanguages.map(lang => (
<Option key={lang.code} value={lang.code}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Typography>{lang.nativeName}</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
({lang.name})
</Typography>
</Box>
</Option>
))}
</Select>
{isRTL && (
<FormHelperText>
This language uses right-to-left (RTL) text direction
</FormHelperText>
)}
</FormControl>
</Box>
<Divider sx={{ my: 3 }} />
<Box sx={{ mb: 3 }}>
<Typography level='title-md' sx={{ mb: 1 }}>
{t('localization.dateFormat')}
</Typography>
<Typography level='body-sm' sx={{ mb: 2 }}>
{t('localization.dateFormatDescription')}
</Typography>
<FormControl>
<Select
value={dateFormat}
onChange={(_, value) => setDateFormat(value)}
sx={{ minWidth: 250 }}
>
{dateFormatOptions.map(option => (
<Option key={option.value} value={option.value}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', width: '100%', gap: 2 }}>
<Typography>{option.label}</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{sampleDate.format(option.value)}
</Typography>
</Box>
</Option>
))}
</Select>
<FormHelperText>
Preview: {sampleDate.format(dateFormat)}
</FormHelperText>
</FormControl>
</Box>
<Divider sx={{ my: 3 }} />
<Box sx={{ mb: 3 }}>
<Typography level='title-md' sx={{ mb: 1 }}>
{t('localization.timeFormat')}
</Typography>
<Typography level='body-sm' sx={{ mb: 2 }}>
{t('localization.timeFormatDescription')}
</Typography>
<FormControl>
<Select
value={timeFormat}
onChange={(_, value) => setTimeFormat(value)}
sx={{ minWidth: 250 }}
>
<Option value={TIME_FORMATS.HOUR_12}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', width: '100%', gap: 2 }}>
<Typography>{t('localization.12hour')}</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{sampleDate.format(TIME_FORMATS.HOUR_12)}
</Typography>
</Box>
</Option>
<Option value={TIME_FORMATS.HOUR_24}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', width: '100%', gap: 2 }}>
<Typography>{t('localization.24hour')}</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{sampleDate.format(TIME_FORMATS.HOUR_24)}
</Typography>
</Box>
</Option>
</Select>
<FormHelperText>
Preview: {sampleDate.format(timeFormat)}
</FormHelperText>
</FormControl>
</Box>
<Divider sx={{ my: 3 }} />
<Box>
<Typography level='title-md' sx={{ mb: 1 }}>
{t('localization.firstDayOfWeek')}
</Typography>
<Typography level='body-sm' sx={{ mb: 2 }}>
{t('localization.firstDayOfWeekDescription')}
</Typography>
<FormControl>
<Box sx={{ display: 'flex', gap: 2 }}>
<Button
variant={firstDayOfWeek === 0 ? 'solid' : 'outlined'}
onClick={() => setFirstDayOfWeek(0)}
>
{t('localization.sunday')}
</Button>
<Button
variant={firstDayOfWeek === 1 ? 'solid' : 'outlined'}
onClick={() => setFirstDayOfWeek(1)}
>
{t('localization.monday')}
</Button>
</Box>
</FormControl>
</Box>
</Card>
</Box>
)
}
export default LocalizationSettings

View File

@@ -23,6 +23,7 @@ import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import RealTimeSettings from '../../components/RealTimeSettings'
import SubscriptionModal from '../../components/SubscriptionModal'
import { useLocalization } from '../../contexts/LocalizationContext'
import Logo from '../../Logo'
import { useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
@@ -46,6 +47,7 @@ import NativeCancelSubscriptionModal from '../Modals/Inputs/NativeCancelSubscrip
import PassowrdChangeModal from '../Modals/Inputs/PasswordChangeModal'
import UserDeletionModal from '../Modals/Inputs/UserDeletionModal'
import APITokenSettings from './APITokenSettings'
import LocalizationSettings from './LocalizationSettings'
import MFASettings from './MFASettings'
import NotificationSetting from './NotificationSetting'
import ProfileSettings from './ProfileSettings'
@@ -58,6 +60,7 @@ const Settings = () => {
const queryClient = useQueryClient()
const { showNotification } = useNotification()
const navigate = useNavigate()
const { formatDate } = useLocalization()
const [userCircles, setUserCircles] = useState([])
const [circleMemberRequests, setCircleMemberRequests] = useState([])
@@ -188,13 +191,13 @@ const Settings = () => {
const getSubscriptionDetails = () => {
if (userProfile?.subscription === 'active') {
return `You are currently subscribed to the Plus plan. Your subscription will renew on ${moment(
return `You are currently subscribed to the Plus plan. Your subscription will renew on ${formatDate(
userProfile?.expiration,
).format('MMM DD, YYYY')}.`
)}.`
} else if (userProfile?.subscription === 'cancelled') {
return `You have cancelled your subscription. Your account will be downgraded to the Free plan on ${moment(
return `You have cancelled your subscription. Your account will be downgraded to the Free plan on ${formatDate(
userProfile?.expiration,
).format('MMM DD, YYYY')}.`
)}.`
} else {
return `You are currently on the Free plan. Upgrade to the Plus plan to unlock more features.`
}
@@ -204,9 +207,7 @@ const Settings = () => {
return `Plus`
} else if (userProfile?.subscription === 'cancelled') {
if (moment().isBefore(userProfile?.expiration)) {
return `Plus(until ${moment(userProfile?.expiration).format(
'MMM DD, YYYY',
)})`
return `Plus(until ${formatDate(userProfile?.expiration)})`
}
return `Free`
} else {
@@ -916,6 +917,16 @@ const Settings = () => {
<ThemeToggle />
</div>
<div className='grid gap-4 py-4' id='localization'>
<Typography level='h3'>Localization</Typography>
<Divider />
<Typography level='body-md'>
Customize language, date format, and regional preferences for your
account. These settings will apply throughout the application.
</Typography>
<LocalizationSettings />
</div>
{/* Modals */}
{confirmModalConfig?.isOpen && (
<ConfirmationModal config={confirmModalConfig} />

View File

@@ -11,10 +11,12 @@ import {
ToggleButtonGroup,
useColorScheme,
} from '@mui/joy'
import { useTranslation } from 'react-i18next'
const ELEMENTID = 'select-theme-mode'
const ThemeToggle = () => {
const { t } = useTranslation('settings')
const { mode, setMode } = useColorScheme()
const [themeMode, setThemeMode] = useStickyState(mode, 'themeMode')
@@ -30,7 +32,7 @@ const ThemeToggle = () => {
id={`${ELEMENTID}-label`}
htmlFor='select-theme-mode'
>
Theme mode
{t('theme.themeMode')}
</FormLabel>
)
@@ -45,13 +47,13 @@ const ThemeToggle = () => {
onChange={handleThemeModeChange}
>
<Button startDecorator={<LightModeOutlined />} value='light'>
Light
{t('theme.light')}
</Button>
<Button startDecorator={<DarkModeOutlined />} value='dark'>
Dark
{t('theme.dark')}
</Button>
<Button startDecorator={<LaptopOutlined />} value='system'>
System
{t('theme.system')}
</Button>
</ToggleButtonGroup>
</div>