From 0367886ac6a3a0ad77f932f1664c01562395e5e7 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 11:56:48 -0500 Subject: [PATCH] feat: add i18n system with English locale Co-Authored-By: Claude Sonnet 4.6 --- src/lib/i18n/index.ts | 46 ++++++++++ src/lib/i18n/locales/en.json | 163 +++++++++++++++++++++++++++++++++++ tests/unit/i18n.test.ts | 26 ++++++ 3 files changed, 235 insertions(+) create mode 100644 src/lib/i18n/index.ts create mode 100644 src/lib/i18n/locales/en.json create mode 100644 tests/unit/i18n.test.ts diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts new file mode 100644 index 0000000..a9af22d --- /dev/null +++ b/src/lib/i18n/index.ts @@ -0,0 +1,46 @@ +type Translations = Record; + +export function createI18n(translations: Translations) { + function t(key: string, vars?: Record): string { + let value = translations[key]; + if (value === undefined) return key; + + if (vars) { + for (const [varName, varValue] of Object.entries(vars)) { + value = value.replaceAll(`{${varName}}`, varValue); + } + } + + return value; + } + + return { t }; +} + +function flattenObject(obj: Record, prefix = ''): Translations { + const result: Translations = {}; + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + Object.assign(result, flattenObject(value as Record, fullKey)); + } else { + result[fullKey] = String(value); + } + } + return result; +} + +let cachedI18n: ReturnType | null = null; + +export async function loadI18n(locale = 'en'): Promise> { + if (cachedI18n) return cachedI18n; + + const localeData = await import(`./locales/${locale}.json`); + const flat = flattenObject(localeData.default || localeData); + cachedI18n = createI18n(flat); + return cachedI18n; +} + +export function resetI18nCache(): void { + cachedI18n = null; +} diff --git a/src/lib/i18n/locales/en.json b/src/lib/i18n/locales/en.json new file mode 100644 index 0000000..8c772e7 --- /dev/null +++ b/src/lib/i18n/locales/en.json @@ -0,0 +1,163 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "create": "Create", + "back": "Back", + "search": "Search", + "filter": "Filter", + "loading": "Loading...", + "noResults": "No results found", + "confirm": "Are you sure?", + "yes": "Yes", + "no": "No", + "submit": "Submit", + "required": "Required", + "optional": "Optional", + "actions": "Actions", + "status": "Status", + "date": "Date", + "name": "Name", + "email": "Email", + "phone": "Phone", + "notes": "Notes", + "description": "Description" + }, + "auth": { + "login": "Log In", + "logout": "Log Out", + "register": "Register", + "email": "Email", + "password": "Password", + "currentPassword": "Current Password", + "newPassword": "New Password", + "confirmPassword": "Confirm Password", + "changePassword": "Change Password", + "invalidCredentials": "Invalid email or password", + "accountInactive": "Account is not active", + "passwordMinLength": "Password must be at least 8 characters", + "passwordsDoNotMatch": "Passwords do not match" + }, + "nav": { + "home": "Home", + "pets": "Our Pets", + "about": "About", + "events": "Events", + "donate": "Donate", + "contact": "Contact", + "apply": "Apply", + "adoptionProcess": "Adoption Process", + "becomeAFoster": "Become a Foster", + "howToHelp": "How to Help", + "volunteer": "Volunteer", + "whyAdopt": "Why Adopt", + "happyTails": "Happy Tails", + "sponsors": "Sponsors", + "fosterResources": "Foster Resources", + "nameGenerator": "Name Generator" + }, + "pets": { + "title": "Our Pets", + "available": "Available for Adoption", + "featured": "Featured Pets", + "noFeatured": "No featured pets at the moment", + "noPets": "No pets available at the moment", + "adoptionFee": "Adoption Fee", + "learnMore": "Learn More", + "applyToAdopt": "Apply to Adopt", + "applyToFoster": "Apply to Foster", + "filterSpecies": "Species", + "filterAge": "Age", + "filterSex": "Sex", + "all": "All" + }, + "applications": { + "adoption": "Adoption Application", + "foster": "Foster Application", + "volunteer": "Volunteer Application", + "surrender": "Surrender Application", + "thankYou": "Thank you for your application!", + "weWillReview": "We will review your application and get back to you soon." + }, + "foster": { + "portal": "Foster Portal", + "myPets": "My Foster Pets", + "submitUpdate": "Submit Update", + "requestSupplies": "Request Supplies", + "noPets": "You don't have any foster pets assigned yet." + }, + "admin": { + "dashboard": "Dashboard", + "pets": "Pets", + "applications": "Applications", + "sponsors": "Sponsors", + "donations": "Donations", + "expenses": "Expenses", + "vets": "Vets", + "events": "Events", + "content": "Content", + "newsletter": "Newsletter", + "happyTails": "Happy Tails", + "users": "Users", + "volunteers": "Volunteers", + "fosters": "Fosters", + "mentors": "Mentors", + "intake": "Intake", + "moderation": "Moderation", + "reports": "Reports", + "analytics": "Analytics", + "traffic": "Live Traffic", + "messages": "Messages", + "import": "Import", + "system": "System", + "fosterDashboard": "My Fosters" + }, + "setup": { + "welcome": "Welcome to FosterFlow!", + "letsGetStarted": "Let's get your rescue set up.", + "step1Title": "Organization Info", + "step2Title": "Branding", + "step3Title": "Application Forms", + "step4Title": "Integrations", + "step5Title": "Import Data", + "next": "Next", + "previous": "Previous", + "finish": "Finish Setup" + }, + "home": { + "hero": "Find Your Forever Friend", + "heroSubtitle": "We rescue, rehabilitate, and rehome animals in need.", + "viewPets": "View Our Pets", + "howToHelp": "How to Help", + "stats": { + "adopted": "Pets Adopted", + "inCare": "Currently in Care", + "fosters": "Active Fosters" + }, + "upcomingEvents": "Upcoming Events", + "recentHappyTails": "Recent Happy Tails" + }, + "contact": { + "title": "Contact Us", + "send": "Send Message", + "sent": "Message sent! We'll get back to you soon." + }, + "newsletter": { + "subscribe": "Subscribe to Our Newsletter", + "emailPlaceholder": "Your email address", + "subscribed": "Thanks for subscribing!" + }, + "donate": { + "title": "Donate", + "support": "Support Our Mission", + "oneTime": "One-Time Donation", + "monthly": "Monthly Donation" + }, + "footer": { + "quickLinks": "Quick Links", + "connect": "Connect With Us", + "copyright": "All rights reserved." + } +} diff --git a/tests/unit/i18n.test.ts b/tests/unit/i18n.test.ts new file mode 100644 index 0000000..d8fc7a0 --- /dev/null +++ b/tests/unit/i18n.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { createI18n } from '$lib/i18n'; + +describe('i18n', () => { + const i18n = createI18n({ + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'pets.title': 'Our Pets', + 'pets.count': '{count} pets available', + 'greeting': 'Hello, {name}!' + }); + + it('returns translated string by key', () => { + expect(i18n.t('common.save')).toBe('Save'); + expect(i18n.t('pets.title')).toBe('Our Pets'); + }); + + it('returns key when translation is missing', () => { + expect(i18n.t('missing.key')).toBe('missing.key'); + }); + + it('interpolates variables', () => { + expect(i18n.t('greeting', { name: 'Justin' })).toBe('Hello, Justin!'); + expect(i18n.t('pets.count', { count: '5' })).toBe('5 pets available'); + }); +});