From 672b17fd13ba746931e0000d268c1cc9e8d0b52b Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 13 Apr 2026 18:19:29 +0200 Subject: [PATCH] feat(i18n): extract strings from navigation, dialogs, onboarding, settings, and login - Add useTranslation() to TopNav, BottomTabBar, FabMenu, UserMenu - Internationalize ConfirmDialog, AuthPromptModal, ExternalLinkDialog - Extract all onboarding flow strings (Welcome, HobbyPicker, ItemBrowser, Review, Done) - Internationalize settings page (weight unit, currency, API keys, import/export) - Internationalize login page and root error boundary - All dialogs in __root.tsx use t() for UI chrome Phase 34, Plan 02 (core navigation and global UI) --- src/client/components/AuthPromptModal.tsx | 10 ++-- src/client/components/BottomTabBar.tsx | 14 +++--- src/client/components/ConfirmDialog.tsx | 12 ++--- src/client/components/ExternalLinkDialog.tsx | 10 ++-- src/client/components/FabMenu.tsx | 8 ++-- src/client/components/TopNav.tsx | 12 +++-- src/client/components/UserMenu.tsx | 10 ++-- .../components/onboarding/OnboardingDone.tsx | 9 ++-- .../onboarding/OnboardingHobbyPicker.tsx | 8 ++-- .../onboarding/OnboardingItemBrowser.tsx | 19 ++++---- .../onboarding/OnboardingReview.tsx | 14 +++--- .../onboarding/OnboardingWelcome.tsx | 10 ++-- src/client/routes/__root.tsx | 29 ++++++------ src/client/routes/login.tsx | 10 ++-- src/client/routes/settings.tsx | 46 ++++++++++--------- 15 files changed, 123 insertions(+), 98 deletions(-) diff --git a/src/client/components/AuthPromptModal.tsx b/src/client/components/AuthPromptModal.tsx index d2eca84..2852eee 100644 --- a/src/client/components/AuthPromptModal.tsx +++ b/src/client/components/AuthPromptModal.tsx @@ -1,7 +1,9 @@ import { Link } from "@tanstack/react-router"; +import { useTranslation } from "react-i18next"; import { useUIStore } from "../stores/uiStore"; export function AuthPromptModal() { + const { t } = useTranslation(); const showAuthPrompt = useUIStore((s) => s.showAuthPrompt); const closeAuthPrompt = useUIStore((s) => s.closeAuthPrompt); @@ -18,10 +20,10 @@ export function AuthPromptModal() { />

- Join GearBox + {t("auth.joinGearBox")}

- To manage your own collection, sign in or sign up. + {t("auth.signInDescription")}

- Sign in + {t("auth.signIn")} - Create account + {t("auth.createAccount")}
diff --git a/src/client/components/BottomTabBar.tsx b/src/client/components/BottomTabBar.tsx index 77d841b..918d0d8 100644 --- a/src/client/components/BottomTabBar.tsx +++ b/src/client/components/BottomTabBar.tsx @@ -1,5 +1,6 @@ import { Link, useMatchRoute } from "@tanstack/react-router"; import { motion } from "framer-motion"; +import { useTranslation } from "react-i18next"; import { useAuth } from "../hooks/useAuth"; import { LucideIcon } from "../lib/iconData"; import { useUIStore } from "../stores/uiStore"; @@ -26,6 +27,7 @@ function TabItemWrapper({ icon, label, isActive }: TabItemProps) { } export function BottomTabBar() { + const { t } = useTranslation(); const { data: auth } = useAuth(); const isAuthenticated = !!auth?.user; const openCatalogSearch = useUIStore((s) => s.openCatalogSearch); @@ -46,7 +48,7 @@ export function BottomTabBar() {
{/* Home tab — always a Link */} - + {/* Collection tab — Link if authenticated, button if anonymous */} @@ -54,7 +56,7 @@ export function BottomTabBar() { @@ -62,7 +64,7 @@ export function BottomTabBar() { @@ -71,17 +73,17 @@ export function BottomTabBar() { {/* Setups tab — Link if authenticated, button if anonymous */} {isAuthenticated ? ( - + ) : ( )} {/* Search tab — always a button, opens CatalogSearchOverlay */}
diff --git a/src/client/components/ConfirmDialog.tsx b/src/client/components/ConfirmDialog.tsx index b1910cb..a463641 100644 --- a/src/client/components/ConfirmDialog.tsx +++ b/src/client/components/ConfirmDialog.tsx @@ -1,7 +1,9 @@ +import { useTranslation } from "react-i18next"; import { useDeleteItem, useItems } from "../hooks/useItems"; import { useUIStore } from "../stores/uiStore"; export function ConfirmDialog() { + const { t } = useTranslation(); const confirmDeleteItemId = useUIStore((s) => s.confirmDeleteItemId); const closeConfirmDelete = useUIStore((s) => s.closeConfirmDelete); const deleteItem = useDeleteItem(); @@ -30,12 +32,10 @@ export function ConfirmDialog() { />

- Delete Item + {t("confirm.deleteItem")}

- Are you sure you want to delete{" "} - {itemName}? This action cannot be - undone. + {t("confirm.deleteItemMessage", { name: itemName })}

diff --git a/src/client/components/ExternalLinkDialog.tsx b/src/client/components/ExternalLinkDialog.tsx index 1f1aa0d..de92c76 100644 --- a/src/client/components/ExternalLinkDialog.tsx +++ b/src/client/components/ExternalLinkDialog.tsx @@ -1,7 +1,9 @@ import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { useUIStore } from "../stores/uiStore"; export function ExternalLinkDialog() { + const { t } = useTranslation(); const externalLinkUrl = useUIStore((s) => s.externalLinkUrl); const closeExternalLink = useUIStore((s) => s.closeExternalLink); @@ -35,9 +37,9 @@ export function ExternalLinkDialog() { />

- You are about to leave GearBox + {t("externalLink.title")}

-

You will be redirected to:

+

{t("externalLink.redirectMessage")}

{externalLinkUrl}

@@ -47,14 +49,14 @@ export function ExternalLinkDialog() { onClick={closeExternalLink} className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" > - Cancel + {t("actions.cancel")}
diff --git a/src/client/components/FabMenu.tsx b/src/client/components/FabMenu.tsx index 2b76c8f..0c057a4 100644 --- a/src/client/components/FabMenu.tsx +++ b/src/client/components/FabMenu.tsx @@ -1,5 +1,6 @@ import { AnimatePresence, motion } from "framer-motion"; import { Package, Plus, Search } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { useUIStore } from "../stores/uiStore"; interface FabMenuProps { @@ -15,6 +16,7 @@ interface MenuItem { } export function FabMenu({ isSetupsPage }: FabMenuProps) { + const { t } = useTranslation(); const fabMenuOpen = useUIStore((s) => s.fabMenuOpen); const openFabMenu = useUIStore((s) => s.openFabMenu); const closeFabMenu = useUIStore((s) => s.closeFabMenu); @@ -26,12 +28,12 @@ export function FabMenu({ isSetupsPage }: FabMenuProps) { const menuItems: MenuItem[] = [ { - label: "Add to Collection", + label: t("fab.addToCollection"), icon: , onClick: () => openCatalogSearch("collection"), }, { - label: "Start New Thread", + label: t("fab.startNewThread"), icon: , onClick: () => openCatalogSearch("thread"), }, @@ -39,7 +41,7 @@ export function FabMenu({ isSetupsPage }: FabMenuProps) { if (isSetupsPage) { menuItems.push({ - label: "New Setup", + label: t("fab.newSetup"), icon: , onClick: () => { closeFabMenu(); diff --git a/src/client/components/TopNav.tsx b/src/client/components/TopNav.tsx index 4ff2af6..fc52c0c 100644 --- a/src/client/components/TopNav.tsx +++ b/src/client/components/TopNav.tsx @@ -1,5 +1,6 @@ import { Link, useMatchRoute, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { useAuth } from "../hooks/useAuth"; import { LucideIcon } from "../lib/iconData"; import { useUIStore } from "../stores/uiStore"; @@ -42,6 +43,7 @@ function NavLinkOrButton({ } export function TopNav() { + const { t } = useTranslation(); const { data: auth } = useAuth(); const isAuthenticated = !!auth?.user; const openAuthPrompt = useUIStore((s) => s.openAuthPrompt); @@ -82,7 +84,7 @@ export function TopNav() { isAuthenticated={isAuthenticated} onAuthPrompt={openAuthPrompt} > - Home + {t("nav.home")} - Collection + {t("nav.collection")} - Setups + {t("nav.setups")} @@ -124,7 +126,7 @@ export function TopNav() { onKeyDown={(e) => { if (e.key === "Enter") handleSearch(); }} - placeholder="Search catalog..." + placeholder={t("nav.searchPlaceholder")} className="bg-gray-50 border border-gray-200 rounded-lg pl-9 pr-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-200 focus:border-gray-300 w-48 lg:w-64 transition-colors" /> @@ -137,7 +139,7 @@ export function TopNav() { to="/login" className="text-xs text-gray-500 hover:text-gray-700 transition-colors" > - Sign in + {t("auth.signIn")} )} diff --git a/src/client/components/UserMenu.tsx b/src/client/components/UserMenu.tsx index 2e68492..984d513 100644 --- a/src/client/components/UserMenu.tsx +++ b/src/client/components/UserMenu.tsx @@ -1,10 +1,12 @@ import { Link } from "@tanstack/react-router"; import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useAuth, useLogout } from "../hooks/useAuth"; import { usePublicProfile } from "../hooks/useProfile"; import { LucideIcon } from "../lib/iconData"; export function UserMenu() { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const menuRef = useRef(null); const { logout } = useLogout(); @@ -34,7 +36,7 @@ export function UserMenu() { {avatarUrl ? ( Profile ) : ( @@ -53,7 +55,7 @@ export function UserMenu() { size={16} className="text-gray-400" /> - Profile + {t("nav.profile")} - Settings + {t("nav.settings")}
)} diff --git a/src/client/components/onboarding/OnboardingDone.tsx b/src/client/components/onboarding/OnboardingDone.tsx index 39e1c80..4331730 100644 --- a/src/client/components/onboarding/OnboardingDone.tsx +++ b/src/client/components/onboarding/OnboardingDone.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { LucideIcon } from "../../lib/iconData"; interface OnboardingDoneProps { @@ -9,6 +10,7 @@ export function OnboardingDone({ itemsCreated: _itemsCreated, onFinish, }: OnboardingDoneProps) { + const { t } = useTranslation("onboarding"); return (
@@ -20,18 +22,17 @@ export function OnboardingDone({ />

- You're all set! + {t("done.title")}

- Your collection is ready. Browse the catalog anytime to discover more - gear. + {t("done.subtitle")}

diff --git a/src/client/components/onboarding/OnboardingHobbyPicker.tsx b/src/client/components/onboarding/OnboardingHobbyPicker.tsx index 962f5ff..b9b42c4 100644 --- a/src/client/components/onboarding/OnboardingHobbyPicker.tsx +++ b/src/client/components/onboarding/OnboardingHobbyPicker.tsx @@ -1,4 +1,5 @@ import { HOBBIES } from "@/shared/hobbyConfig"; +import { useTranslation } from "react-i18next"; import { HobbyCard } from "./HobbyCard"; interface OnboardingHobbyPickerProps { @@ -12,14 +13,15 @@ export function OnboardingHobbyPicker({ onToggleHobby, onContinue, }: OnboardingHobbyPickerProps) { + const { t } = useTranslation("onboarding"); return (

- What are you into? + {t("hobby.title")}

- Pick one or more — we'll show you popular gear for each. + {t("hobby.subtitle")}

{HOBBIES.map((hobby) => ( @@ -39,7 +41,7 @@ export function OnboardingHobbyPicker({ disabled={selectedHobbies.length === 0} className="px-8 py-3 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors" > - Continue + {t("hobby.continue")}
diff --git a/src/client/components/onboarding/OnboardingItemBrowser.tsx b/src/client/components/onboarding/OnboardingItemBrowser.tsx index 8b62a22..84014db 100644 --- a/src/client/components/onboarding/OnboardingItemBrowser.tsx +++ b/src/client/components/onboarding/OnboardingItemBrowser.tsx @@ -1,4 +1,5 @@ import { getTagsForHobbies } from "@/shared/hobbyConfig"; +import { useTranslation } from "react-i18next"; import { usePopularItems } from "../../hooks/useOnboarding"; import { SelectableItemCard } from "./SelectableItemCard"; @@ -17,6 +18,7 @@ export function OnboardingItemBrowser({ onContinue, onSkip, }: OnboardingItemBrowserProps) { + const { t } = useTranslation("onboarding"); const tags = getTagsForHobbies(selectedHobbies); const { data: items, isLoading } = usePopularItems(tags); @@ -48,11 +50,12 @@ export function OnboardingItemBrowser({

- Popular gear for{" "} - {selectedHobbies.length === 1 ? selectedHobbies[0] : "your hobbies"} + {selectedHobbies.length === 1 + ? t("items.title", { hobby: selectedHobbies[0] }) + : t("items.titleMultiple")}

- Tap items you already own. We'll add them to your collection. + {t("items.subtitle")}

{isLoading && ( @@ -64,11 +67,10 @@ export function OnboardingItemBrowser({ {!isLoading && !hasItems && (

- No gear cataloged yet + {t("items.noCatalog")}

- We're still building our catalog for this hobby. You can skip this - step and add gear manually later. + {t("items.noCatalogDescription")}

)} @@ -105,8 +107,7 @@ export function OnboardingItemBrowser({ onClick={onContinue} className="px-8 py-3 bg-gray-700 hover:bg-gray-800 text-white font-medium rounded-lg transition-colors" > - Review {selectedItemIds.size}{" "} - {selectedItemIds.size === 1 ? "item" : "items"} + {t("items.reviewCount", { count: selectedItemIds.size })} )}
diff --git a/src/client/components/onboarding/OnboardingReview.tsx b/src/client/components/onboarding/OnboardingReview.tsx index 7891b78..b3c872f 100644 --- a/src/client/components/onboarding/OnboardingReview.tsx +++ b/src/client/components/onboarding/OnboardingReview.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { LucideIcon } from "../../lib/iconData"; interface ReviewItem { @@ -23,6 +24,7 @@ export function OnboardingReview({ onSkip, isSubmitting, }: OnboardingReviewProps) { + const { t } = useTranslation("onboarding"); // Group by category const grouped = new Map(); for (const item of items) { @@ -35,12 +37,12 @@ export function OnboardingReview({

- Your starting collection + {t("review.title")}

{items.length > 0 - ? `${items.length} ${items.length === 1 ? "item" : "items"} ready to add` - : "No items selected — you can always add gear later from the catalog."} + ? t("review.itemsReady", { count: items.length }) + : t("review.noItemsSelected")}

{items.length > 0 && ( @@ -101,7 +103,7 @@ export function OnboardingReview({ disabled={isSubmitting} className="px-8 py-3 bg-gray-700 hover:bg-gray-800 disabled:opacity-50 text-white font-medium rounded-lg transition-colors" > - {isSubmitting ? "Adding..." : "Add to my collection"} + {isSubmitting ? t("review.adding") : t("review.addToCollection")} ) : ( )} {items.length > 0 && ( @@ -118,7 +120,7 @@ export function OnboardingReview({ onClick={onSkip} className="text-sm text-gray-400 hover:text-gray-600 transition-colors" > - Skip this step + {t("common:actions.skipStep")} )}
diff --git a/src/client/components/onboarding/OnboardingWelcome.tsx b/src/client/components/onboarding/OnboardingWelcome.tsx index d3c125d..c923e04 100644 --- a/src/client/components/onboarding/OnboardingWelcome.tsx +++ b/src/client/components/onboarding/OnboardingWelcome.tsx @@ -1,24 +1,26 @@ +import { useTranslation } from "react-i18next"; + interface OnboardingWelcomeProps { onContinue: () => void; } export function OnboardingWelcome({ onContinue }: OnboardingWelcomeProps) { + const { t } = useTranslation("onboarding"); return (

- Welcome to GearBox + {t("welcome.title")}

- Tell us what you're into, and we'll help you set up your collection - with gear that people actually use. + {t("welcome.subtitle")}

diff --git a/src/client/routes/__root.tsx b/src/client/routes/__root.tsx index 3479ee5..be51c63 100644 --- a/src/client/routes/__root.tsx +++ b/src/client/routes/__root.tsx @@ -8,6 +8,7 @@ import { useRouter, } from "@tanstack/react-router"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { Toaster } from "sonner"; import "../app.css"; import { AddToCollectionModal } from "../components/AddToCollectionModal"; @@ -33,6 +34,7 @@ export const Route = createRootRoute({ function RootErrorBoundary({ error, reset }: ErrorComponentProps) { const router = useRouter(); + const { t } = useTranslation(); return (
@@ -53,12 +55,12 @@ function RootErrorBoundary({ error, reset }: ErrorComponentProps) {

- Something went wrong + {t("errors.somethingWentWrong")}

{error instanceof Error ? error.message - : "An unexpected error occurred"} + : t("errors.unexpectedError")}

@@ -205,6 +207,7 @@ function CandidateDeleteDialog({ threadId: number; onClose: () => void; }) { + const { t } = useTranslation(); const deleteCandidate = useDeleteCandidate(threadId); const { data: thread } = useThread(threadId); const candidate = thread?.candidates.find((c) => c.id === candidateId); @@ -227,12 +230,10 @@ function CandidateDeleteDialog({ />

- Delete Candidate + {t("confirm.deleteCandidate")}

- Are you sure you want to delete{" "} - {candidateName}? This action - cannot be undone. + {t("confirm.deleteCandidateMessage", { name: candidateName })}

@@ -267,6 +268,7 @@ function ResolveDialog({ onClose: () => void; onResolved: () => void; }) { + const { t } = useTranslation(); const resolveThread = useResolveThread(); const { data: thread } = useThread(threadId); const candidate = thread?.candidates.find((c) => c.id === candidateId); @@ -290,11 +292,10 @@ function ResolveDialog({ />

- Pick Winner + {t("confirm.pickWinner")}

- Pick {candidateName} as the - winner? This will add it to your collection and archive the thread. + {t("confirm.pickWinnerMessage", { name: candidateName })}

diff --git a/src/client/routes/login.tsx b/src/client/routes/login.tsx index f5a62e3..248b4d6 100644 --- a/src/client/routes/login.tsx +++ b/src/client/routes/login.tsx @@ -1,5 +1,6 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; import { useAuth } from "../hooks/useAuth"; export const Route = createFileRoute("/login")({ @@ -7,6 +8,7 @@ export const Route = createFileRoute("/login")({ }); function LoginPage() { + const { t } = useTranslation(); const navigate = useNavigate(); const { data: auth, isLoading } = useAuth(); @@ -19,7 +21,7 @@ function LoginPage() { if (isLoading) { return (
-

Loading...

+

{t("actions.loading")}

); } @@ -28,11 +30,11 @@ function LoginPage() {

- Sign in to GearBox + {t("auth.signInToGearBox")}

- You will be redirected to sign in with your account. + {t("auth.redirectDescription")}

diff --git a/src/client/routes/settings.tsx b/src/client/routes/settings.tsx index 65f6739..80fe245 100644 --- a/src/client/routes/settings.tsx +++ b/src/client/routes/settings.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Link } from "@tanstack/react-router"; import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useApiKeys, useAuth, @@ -27,6 +28,7 @@ export const Route = createFileRoute("/settings")({ }); function ApiKeySection() { + const { t } = useTranslation("settings"); const { data: keys } = useApiKeys(); const createKey = useCreateApiKey(); const deleteKey = useDeleteApiKey(); @@ -42,16 +44,15 @@ function ApiKeySection() { return (
-

API Keys

+

{t("apiKeys.title")}

- API keys allow programmatic access to GearBox (e.g., from Claude Desktop - or scripts). + {t("apiKeys.description")}

{newKey && (

- Copy this key now — it won't be shown again: + {t("apiKeys.copyWarning")}

{newKey} @@ -61,7 +62,7 @@ function ApiKeySection() { onClick={() => setNewKey(null)} className="mt-2 block text-xs text-amber-700 hover:text-amber-900" > - Dismiss + {t("common:actions.dismiss")}
)} @@ -69,7 +70,7 @@ function ApiKeySection() {
setName(e.target.value)} required @@ -80,7 +81,7 @@ function ApiKeySection() { disabled={createKey.isPending} className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 disabled:opacity-50 rounded-lg transition-colors" > - Create + {t("common:actions.create")}
@@ -102,7 +103,7 @@ function ApiKeySection() { onClick={() => deleteKey.mutate(key.id)} className="text-xs text-red-500 hover:text-red-700" > - Revoke + {t("common:actions.revoke")}
))} @@ -113,6 +114,7 @@ function ApiKeySection() { } function ImportExportSection() { + const { t } = useTranslation("settings"); const exportItems = useExportItems(); const importItems = useImportItems(); const fileInputRef = useRef(null); @@ -142,9 +144,9 @@ function ImportExportSection() { return (
-

Import / Export

+

{t("importExport.title")}

- Export your gear collection as a CSV file, or import items from a CSV. + {t("importExport.description")}

@@ -153,11 +155,11 @@ function ImportExportSection() { onClick={exportItems} className="px-4 py-2 text-sm font-medium text-white bg-gray-700 hover:bg-gray-800 rounded-lg transition-colors" > - Export CSV + {t("importExport.export")}
)} @@ -228,6 +229,7 @@ function getSuggestedCurrency(): Currency | null { } function SettingsPage() { + const { t } = useTranslation("settings"); const unit = useWeightUnit(); const { currency, showConversions } = useCurrency(); const updateSetting = useUpdateSetting(); @@ -245,9 +247,9 @@ function SettingsPage() { to="/" className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block" > - ← Back + ← {t("common:actions.back")} -

Settings

+

{t("title")}

{showSuggestion && ( @@ -279,9 +281,9 @@ function SettingsPage() {
-

Weight Unit

+

{t("weightUnit.title")}

- Choose the unit used to display weights across the app + {t("weightUnit.description")}

@@ -312,10 +314,10 @@ function SettingsPage() {

- Market & Currency + {t("currency.title")}

- Sets your market region and currency for price display + {t("currency.description")}