feat: Implement frontend localization with i18n and measurement units
Implemented comprehensive frontend localization infrastructure supporting 5 languages (English, Spanish, French, Portuguese, Chinese) with measurement unit preferences (Metric/Imperial). This lays the foundation for international user support. **Core Infrastructure:** - Installed i18next, react-i18next, i18next-browser-languagedetector - Created I18nProvider component integrated into app layout - Configured i18next with language detection and localStorage persistence - Created 35 translation files (5 languages × 7 namespaces) **Translation Namespaces:** - common: App-wide UI elements, navigation, actions - tracking: Activity tracking (feeding, sleep, diaper, milestones) - ai: AI assistant chat interface - auth: Authentication flows (login, signup, password reset) - settings: Settings and preferences - onboarding: Onboarding flow - errors: Error messages and validation **Custom Hooks:** - useTranslation: Type-safe translation wrapper - useLocale: Language and measurement system management - useFormatting: Date, time, number, and unit formatting **Measurement Unit Support:** - Created unit conversion utilities (weight, height, temperature, volume) - Metric: kg, cm, °C, ml - Imperial: lb, in, °F, oz - Bidirectional conversion functions **UI Components:** - LanguageSelector: Dropdown to change app language - MeasurementUnitSelector: Toggle between Metric/Imperial - Integrated both into Settings page Preferences section **Next Steps (Remaining):** - Add measurement preferences to backend user schema - Create onboarding flow with language/measurement selection - Apply translations to existing components (dashboard, tracking forms) - Implement multi-language AI responses - Add professional translations (currently using basic translations) **File Highlights:** - lib/i18n/config.ts: i18next configuration - hooks/useFormatting.ts: Formatting utilities with locale support - lib/utils/unitConversion.ts: Unit conversion logic - components/settings/*Selector.tsx: Language and measurement selectors - locales/*/: Translation files for 5 languages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
133
maternal-web/hooks/useFormatting.ts
Normal file
133
maternal-web/hooks/useFormatting.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocale } from './useLocale';
|
||||
import {
|
||||
convertWeight,
|
||||
convertHeight,
|
||||
convertTemperature,
|
||||
convertVolume,
|
||||
} from '@/lib/utils/unitConversion';
|
||||
|
||||
/**
|
||||
* Custom hook for formatting dates, times, numbers, and measurements
|
||||
* based on the user's locale and measurement preferences
|
||||
*/
|
||||
export function useFormatting() {
|
||||
const { language, measurementSystem } = useLocale();
|
||||
|
||||
const formatDate = useCallback(
|
||||
(date: Date | string, options?: Intl.DateTimeFormatOptions) => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat(language, options || {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(dateObj);
|
||||
},
|
||||
[language]
|
||||
);
|
||||
|
||||
const formatTime = useCallback(
|
||||
(date: Date | string, options?: Intl.DateTimeFormatOptions) => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat(language, options || {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(dateObj);
|
||||
},
|
||||
[language]
|
||||
);
|
||||
|
||||
const formatDateTime = useCallback(
|
||||
(date: Date | string, options?: Intl.DateTimeFormatOptions) => {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat(language, options || {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(dateObj);
|
||||
},
|
||||
[language]
|
||||
);
|
||||
|
||||
const formatNumber = useCallback(
|
||||
(value: number, options?: Intl.NumberFormatOptions) => {
|
||||
return new Intl.NumberFormat(language, options).format(value);
|
||||
},
|
||||
[language]
|
||||
);
|
||||
|
||||
const formatWeight = useCallback(
|
||||
(valueInKg: number, showUnit: boolean = true) => {
|
||||
const converted = convertWeight(valueInKg, measurementSystem);
|
||||
const formatted = formatNumber(converted.value, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return showUnit ? `${formatted} ${converted.unit}` : formatted;
|
||||
},
|
||||
[measurementSystem, formatNumber]
|
||||
);
|
||||
|
||||
const formatHeight = useCallback(
|
||||
(valueInCm: number, showUnit: boolean = true) => {
|
||||
const converted = convertHeight(valueInCm, measurementSystem);
|
||||
const formatted = formatNumber(converted.value, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
return showUnit ? `${formatted} ${converted.unit}` : formatted;
|
||||
},
|
||||
[measurementSystem, formatNumber]
|
||||
);
|
||||
|
||||
const formatTemperature = useCallback(
|
||||
(valueInCelsius: number, showUnit: boolean = true) => {
|
||||
const converted = convertTemperature(valueInCelsius, measurementSystem);
|
||||
const formatted = formatNumber(converted.value, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
return showUnit ? `${formatted}${converted.unit}` : formatted;
|
||||
},
|
||||
[measurementSystem, formatNumber]
|
||||
);
|
||||
|
||||
const formatVolume = useCallback(
|
||||
(valueInMl: number, showUnit: boolean = true) => {
|
||||
const converted = convertVolume(valueInMl, measurementSystem);
|
||||
const formatted = formatNumber(converted.value, {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
return showUnit ? `${formatted} ${converted.unit}` : formatted;
|
||||
},
|
||||
[measurementSystem, formatNumber]
|
||||
);
|
||||
|
||||
const formatDuration = useCallback(
|
||||
(minutes: number) => {
|
||||
if (minutes < 60) {
|
||||
return `${minutes} min`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}min` : `${hours}h`;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
formatDate,
|
||||
formatTime,
|
||||
formatDateTime,
|
||||
formatNumber,
|
||||
formatWeight,
|
||||
formatHeight,
|
||||
formatTemperature,
|
||||
formatVolume,
|
||||
formatDuration,
|
||||
measurementSystem,
|
||||
};
|
||||
}
|
||||
46
maternal-web/hooks/useLocale.ts
Normal file
46
maternal-web/hooks/useLocale.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from './useTranslation';
|
||||
import { supportedLanguages } from '@/lib/i18n/config';
|
||||
|
||||
export type MeasurementSystem = 'metric' | 'imperial';
|
||||
|
||||
/**
|
||||
* Custom hook for managing locale and measurement preferences
|
||||
*/
|
||||
export function useLocale() {
|
||||
const { i18n, language, changeLanguage } = useTranslation();
|
||||
|
||||
const setLanguage = useCallback(
|
||||
async (lang: string) => {
|
||||
await changeLanguage(lang);
|
||||
localStorage.setItem('preferred-language', lang);
|
||||
},
|
||||
[changeLanguage]
|
||||
);
|
||||
|
||||
const getCurrentLanguage = useCallback(() => {
|
||||
return supportedLanguages.find((lang) => lang.code === language) || supportedLanguages[0];
|
||||
}, [language]);
|
||||
|
||||
const getMeasurementSystem = useCallback((): MeasurementSystem => {
|
||||
const stored = localStorage.getItem('measurement-system');
|
||||
return (stored as MeasurementSystem) || 'metric';
|
||||
}, []);
|
||||
|
||||
const setMeasurementSystem = useCallback((system: MeasurementSystem) => {
|
||||
localStorage.setItem('measurement-system', system);
|
||||
// Trigger a custom event so other components can react to the change
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('measurement-system-changed', { detail: system })
|
||||
);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
language,
|
||||
setLanguage,
|
||||
currentLanguage: getCurrentLanguage(),
|
||||
supportedLanguages,
|
||||
measurementSystem: getMeasurementSystem(),
|
||||
setMeasurementSystem,
|
||||
};
|
||||
}
|
||||
16
maternal-web/hooks/useTranslation.ts
Normal file
16
maternal-web/hooks/useTranslation.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useTranslation as useI18NextTranslation } from 'react-i18next';
|
||||
|
||||
/**
|
||||
* Custom hook that wraps react-i18next's useTranslation hook
|
||||
* Provides type-safe translation function with namespace support
|
||||
*/
|
||||
export function useTranslation(namespace?: string) {
|
||||
const { t, i18n } = useI18NextTranslation(namespace);
|
||||
|
||||
return {
|
||||
t,
|
||||
i18n,
|
||||
language: i18n.language,
|
||||
changeLanguage: i18n.changeLanguage,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user