From 8c0fb31df25131cb61763d45563a363c313e1d7c Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Apr 2026 18:13:55 +0200 Subject: [PATCH] feat(i18n): install react-i18next, create English locale files, and initialize i18n framework - Install i18next, react-i18next, i18next-browser-languagedetector - Create 6 namespace JSON files (common, collection, threads, setups, onboarding, settings) - Initialize i18n with language detection (localStorage + navigator) - Wire i18n import in main.tsx before React rendering Phase 34, Plan 01 --- bun.lock | 15 +++++ package.json | 3 + src/client/lib/i18n.ts | 39 +++++++++++++ src/client/locales/en/collection.json | 31 +++++++++++ src/client/locales/en/common.json | 80 +++++++++++++++++++++++++++ src/client/locales/en/onboarding.json | 34 ++++++++++++ src/client/locales/en/settings.json | 32 +++++++++++ src/client/locales/en/setups.json | 43 ++++++++++++++ src/client/locales/en/threads.json | 45 +++++++++++++++ src/client/main.tsx | 1 + 10 files changed, 323 insertions(+) create mode 100644 src/client/lib/i18n.ts create mode 100644 src/client/locales/en/collection.json create mode 100644 src/client/locales/en/common.json create mode 100644 src/client/locales/en/onboarding.json create mode 100644 src/client/locales/en/settings.json create mode 100644 src/client/locales/en/setups.json create mode 100644 src/client/locales/en/threads.json diff --git a/bun.lock b/bun.lock index 28748af..6a74741 100644 --- a/bun.lock +++ b/bun.lock @@ -17,11 +17,14 @@ "drizzle-orm": "^0.45.1", "framer-motion": "^12.38.0", "hono": "^4.12.8", + "i18next": "^26.0.4", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.577.0", "postgres": "^3.4.9", "react": "^19.2.4", "react-dom": "^19.2.4", "react-easy-crop": "^5.5.7", + "react-i18next": "^17.0.2", "recharts": "^3.8.0", "sharp": "^0.34.5", "sonner": "^2.0.7", @@ -166,6 +169,8 @@ "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], @@ -794,8 +799,14 @@ "hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="], + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "i18next": ["i18next@26.0.4", "", { "dependencies": { "@babel/runtime": "^7.29.2" }, "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-gXF7U9bfioXPLv7mw8Qt2nfO7vij5MyINvPgVv99pX3fL1Y01pw2mKBFrlYpRxRCl2wz3ISenj6VsMJT2isfuA=="], + + "i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], @@ -964,6 +975,8 @@ "react-easy-crop": ["react-easy-crop@5.5.7", "", { "dependencies": { "normalize-wheel": "^1.0.1", "tslib": "^2.0.1" }, "peerDependencies": { "react": ">=16.4.0", "react-dom": ">=16.4.0" } }, "sha512-kYo4NtMeXFQB7h1U+h5yhUkE46WQbQdq7if54uDlbMdZHdRgNehfvaFrXnFw5NR1PNoUOJIfTwLnWmEx/MaZnA=="], + "react-i18next": ["react-i18next@17.0.2", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.0.1", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA=="], + "react-is": ["react-is@19.2.4", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="], "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], @@ -1102,6 +1115,8 @@ "vite": ["vite@8.0.0", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", "picomatch": "^4.0.3", "postcss": "^8.5.8", "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q=="], + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], diff --git a/package.json b/package.json index ac20801..7eb29ec 100644 --- a/package.json +++ b/package.json @@ -48,11 +48,14 @@ "drizzle-orm": "^0.45.1", "framer-motion": "^12.38.0", "hono": "^4.12.8", + "i18next": "^26.0.4", + "i18next-browser-languagedetector": "^8.2.1", "lucide-react": "^0.577.0", "postgres": "^3.4.9", "react": "^19.2.4", "react-dom": "^19.2.4", "react-easy-crop": "^5.5.7", + "react-i18next": "^17.0.2", "recharts": "^3.8.0", "sharp": "^0.34.5", "sonner": "^2.0.7", diff --git a/src/client/lib/i18n.ts b/src/client/lib/i18n.ts new file mode 100644 index 0000000..df34245 --- /dev/null +++ b/src/client/lib/i18n.ts @@ -0,0 +1,39 @@ +import i18n from "i18next"; +import LanguageDetector from "i18next-browser-languagedetector"; +import { initReactI18next } from "react-i18next"; + +import enCollection from "../locales/en/collection.json"; +import enCommon from "../locales/en/common.json"; +import enOnboarding from "../locales/en/onboarding.json"; +import enSettings from "../locales/en/settings.json"; +import enSetups from "../locales/en/setups.json"; +import enThreads from "../locales/en/threads.json"; + +i18n + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources: { + en: { + common: enCommon, + collection: enCollection, + threads: enThreads, + setups: enSetups, + onboarding: enOnboarding, + settings: enSettings, + }, + }, + supportedLngs: ["en", "de"], + fallbackLng: "en", + defaultNS: "common", + interpolation: { + escapeValue: false, + }, + detection: { + order: ["localStorage", "navigator"], + lookupLocalStorage: "gearbox-language", + caches: ["localStorage"], + }, + }); + +export default i18n; diff --git a/src/client/locales/en/collection.json b/src/client/locales/en/collection.json new file mode 100644 index 0000000..68fe1c1 --- /dev/null +++ b/src/client/locales/en/collection.json @@ -0,0 +1,31 @@ +{ + "title": "Collection", + "gear": "Gear", + "planning": "Planning", + "empty": { + "title": "Your collection is empty", + "description": "Start cataloging your gear by adding your first item. Track weight, price, and organize by category.", + "addFirst": "Add your first item" + }, + "form": { + "name": "Name", + "nameRequired": "Name *", + "namePlaceholder": "e.g. Osprey Talon 22", + "weight": "Weight (g)", + "weightPlaceholder": "e.g. 680", + "price": "Price ($)", + "pricePlaceholder": "e.g. 129.99", + "quantity": "Quantity", + "category": "Category", + "notes": "Notes", + "notesPlaceholder": "Any additional notes...", + "productLink": "Product Link", + "urlPlaceholder": "https://..." + }, + "classification": { + "ultralight": "Ultralight", + "light": "Light", + "medium": "Medium", + "heavy": "Heavy" + } +} diff --git a/src/client/locales/en/common.json b/src/client/locales/en/common.json new file mode 100644 index 0000000..d291073 --- /dev/null +++ b/src/client/locales/en/common.json @@ -0,0 +1,80 @@ +{ + "nav": { + "home": "Home", + "collection": "Collection", + "setups": "Setups", + "discover": "Discover", + "settings": "Settings", + "search": "Search", + "searchPlaceholder": "Search catalog...", + "profile": "Profile" + }, + "actions": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "create": "Create", + "close": "Close", + "back": "Back", + "confirm": "Confirm", + "continue": "Continue", + "tryAgain": "Try again", + "dismiss": "Dismiss", + "saving": "Saving...", + "deleting": "Deleting...", + "creating": "Creating...", + "loading": "Loading...", + "addItem": "Add Item", + "saveChanges": "Save Changes", + "revoke": "Revoke", + "skipStep": "Skip this step" + }, + "errors": { + "somethingWentWrong": "Something went wrong", + "unexpectedError": "An unexpected error occurred", + "nameRequired": "Name is required", + "positiveNumber": "Must be a positive number", + "validUrl": "Must be a valid URL (https://...)" + }, + "auth": { + "signIn": "Sign in", + "signOut": "Sign out", + "joinGearBox": "Join GearBox", + "signInToGearBox": "Sign in to GearBox", + "signInDescription": "To manage your own collection, sign in or sign up.", + "createAccount": "Create account", + "redirectDescription": "You will be redirected to sign in with your account." + }, + "confirm": { + "deleteItem": "Delete Item", + "deleteItemMessage": "Are you sure you want to delete {{name}}? This action cannot be undone.", + "deleteCandidate": "Delete Candidate", + "deleteCandidateMessage": "Are you sure you want to delete {{name}}? This action cannot be undone.", + "pickWinner": "Pick Winner", + "pickWinnerMessage": "Pick {{name}} as the winner? This will add it to your collection and archive the thread." + }, + "externalLink": { + "title": "You are about to leave GearBox", + "redirectMessage": "You will be redirected to:" + }, + "fab": { + "addToCollection": "Add to Collection", + "startNewThread": "Start New Thread", + "newSetup": "New Setup" + }, + "empty": { + "noResults": "No results found", + "noItems": "No items match your search" + }, + "stats": { + "items": "Items", + "totalWeight": "Total Weight", + "totalSpent": "Total Spent" + }, + "filter": { + "showing": "Showing {{filtered}} of {{total}} items", + "searchItems": "Search items...", + "allCategories": "All Categories" + } +} diff --git a/src/client/locales/en/onboarding.json b/src/client/locales/en/onboarding.json new file mode 100644 index 0000000..5df40b3 --- /dev/null +++ b/src/client/locales/en/onboarding.json @@ -0,0 +1,34 @@ +{ + "welcome": { + "title": "Welcome to GearBox", + "subtitle": "Tell us what you're into, and we'll help you set up your collection with gear that people actually use.", + "cta": "Let's go" + }, + "hobby": { + "title": "What are you into?", + "subtitle": "Pick one or more — we'll show you popular gear for each.", + "continue": "Continue" + }, + "items": { + "title": "Popular gear for {{hobby}}", + "titleMultiple": "Popular gear for your hobbies", + "subtitle": "Tap items you already own. We'll add them to your collection.", + "noCatalog": "No gear cataloged yet", + "noCatalogDescription": "We're still building our catalog for this hobby. You can skip this step and add gear manually later.", + "reviewCount": "Review {{count}} items", + "reviewCount_one": "Review {{count}} item" + }, + "review": { + "title": "Your starting collection", + "itemsReady": "{{count}} items ready to add", + "itemsReady_one": "{{count}} item ready to add", + "noItemsSelected": "No items selected — you can always add gear later from the catalog.", + "addToCollection": "Add to my collection", + "adding": "Adding..." + }, + "done": { + "title": "You're all set!", + "subtitle": "Your collection is ready. Browse the catalog anytime to discover more gear.", + "cta": "Start exploring" + } +} diff --git a/src/client/locales/en/settings.json b/src/client/locales/en/settings.json new file mode 100644 index 0000000..3fd58f6 --- /dev/null +++ b/src/client/locales/en/settings.json @@ -0,0 +1,32 @@ +{ + "title": "Settings", + "language": { + "title": "Language", + "description": "Change the display language of the app" + }, + "weightUnit": { + "title": "Weight Unit", + "description": "Choose the unit used to display weights across the app" + }, + "currency": { + "title": "Currency", + "description": "Changes the currency symbol displayed. This does not convert values." + }, + "apiKeys": { + "title": "API Keys", + "description": "API keys allow programmatic access to GearBox (e.g., from Claude Desktop or scripts).", + "copyWarning": "Copy this key now — it won't be shown again:", + "namePlaceholder": "Key name (e.g., claude-desktop)" + }, + "importExport": { + "title": "Import / Export", + "description": "Export your gear collection as a CSV file, or import items from a CSV.", + "export": "Export CSV", + "import": "Import CSV", + "importing": "Importing...", + "imported": "{{count}} items imported.", + "imported_one": "{{count}} item imported.", + "newCategories": "New categories: {{categories}}", + "noItemsFound": "No items found in the CSV." + } +} diff --git a/src/client/locales/en/setups.json b/src/client/locales/en/setups.json new file mode 100644 index 0000000..4b44a0a --- /dev/null +++ b/src/client/locales/en/setups.json @@ -0,0 +1,43 @@ +{ + "title": "Setups", + "create": "New Setup", + "empty": { + "title": "No setups yet", + "description": "Create a setup to organize gear for specific trips or activities." + }, + "card": { + "items": "{{count}} items", + "items_one": "{{count}} item", + "weight": "Weight", + "price": "Price" + }, + "share": { + "title": "Share Setup", + "shareLinks": "Share Links", + "createLink": "Create Link", + "noLinks": "No share links yet", + "copyLink": "Copy link", + "revokeLink": "Revoke link", + "copied": "Copied!", + "noExpiration": "No expiration", + "expired": "Expired", + "expiresToday": "Expires today", + "expiresTomorrow": "Expires tomorrow", + "expiresInDays": "Expires in {{days}} days", + "daysOption": "{{days}} days", + "deactivateWarning": "Switching to private will deactivate all share links. They can be reactivated by switching back." + }, + "visibility": { + "private": "Private", + "privateDescription": "Only you can access", + "link": "Link sharing", + "linkDescription": "Anyone with the link", + "public": "Public", + "publicDescription": "Visible on your profile" + }, + "impact": { + "title": "Impact Preview", + "adding": "Adding", + "removing": "Removing" + } +} diff --git a/src/client/locales/en/threads.json b/src/client/locales/en/threads.json new file mode 100644 index 0000000..dcb68b6 --- /dev/null +++ b/src/client/locales/en/threads.json @@ -0,0 +1,45 @@ +{ + "title": "Research Threads", + "create": { + "title": "New Thread", + "threadName": "Thread name", + "namePlaceholder": "e.g. Lightweight sleeping bag", + "category": "Category", + "nameRequired": "Thread name is required", + "selectCategory": "Please select a category", + "createFailed": "Failed to create thread", + "createThread": "Create Thread" + }, + "status": { + "active": "Active", + "researching": "Researching", + "ordered": "Ordered", + "arrived": "Arrived", + "resolved": "Resolved", + "archived": "Archived" + }, + "candidate": { + "name": "Name", + "price": "Price", + "weight": "Weight", + "url": "URL", + "pros": "Pros", + "cons": "Cons", + "notes": "Notes", + "addCandidate": "Add Candidate" + }, + "comparison": { + "weight": "Weight", + "price": "Price", + "pros": "Pros", + "cons": "Cons" + }, + "resolve": { + "title": "Pick Winner", + "message": "Pick {{name}} as the winner? This will add it to your collection and archive the thread." + }, + "empty": { + "noThreads": "No research threads yet", + "noCandidates": "No candidates yet" + } +} diff --git a/src/client/main.tsx b/src/client/main.tsx index e72c4d8..a94dd32 100644 --- a/src/client/main.tsx +++ b/src/client/main.tsx @@ -1,3 +1,4 @@ +import "./lib/i18n"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRouter, RouterProvider } from "@tanstack/react-router"; import { StrictMode } from "react";