feat(34-06): wire useTranslation into 10 remaining components

- ThreadTabs: tab labels (gear, planning, setups) via collection namespace
- PlanningView: section title, tab labels, empty state steps, CTAs via threads namespace
- TotalsBar: 'Sign in' link via common.auth.signIn
- ThreadCard: resolved badge and candidate count (plural) via threads namespace
- PublicSetupCard: by/anonymous and item count (plural) via setups namespace
- SetupImpactSelector: compare dropdown placeholder via setups.impact.compareWith
- ClassificationBadge: base/worn/consumable labels via collection.classificationBadge
- ImpactDeltaBadge: add mode label via setups.impact.adding
- ImageUpload: click-to-add, error messages via common.imageUpload
- DashboardCard: skipped (renders props only, no hardcoded UI strings)
- Add card, planning keys to en/de threads.json
- Add classificationBadge, tabs, totals keys to en/de collection.json
- Add card.by, card.anonymous, impact.compareWith to en/de setups.json
- Add imageUpload keys to en/de common.json
- Build passes, all 19 i18n parity tests pass
This commit is contained in:
2026-04-17 20:26:50 +02:00
parent 755c0ab89f
commit 480abdd17f
17 changed files with 133 additions and 44 deletions

View File

@@ -1,8 +1,4 @@
const CLASSIFICATION_LABELS: Record<string, string> = { import { useTranslation } from "react-i18next";
base: "Base Weight",
worn: "Worn",
consumable: "Consumable",
};
interface ClassificationBadgeProps { interface ClassificationBadgeProps {
classification: string; classification: string;
@@ -13,7 +9,10 @@ export function ClassificationBadge({
classification, classification,
onCycle, onCycle,
}: ClassificationBadgeProps) { }: ClassificationBadgeProps) {
const label = CLASSIFICATION_LABELS[classification] ?? "Base Weight"; const { t } = useTranslation("collection");
const label = t(`classificationBadge.${classification}`, {
defaultValue: t("classificationBadge.base"),
});
return ( return (
<button <button

View File

@@ -1,4 +1,5 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiUpload } from "../lib/api"; import { apiUpload } from "../lib/api";
import { GearImage, imageContainerBg } from "./GearImage"; import { GearImage, imageContainerBg } from "./GearImage";
import { ImageCropEditor } from "./ImageCropEditor"; import { ImageCropEditor } from "./ImageCropEditor";
@@ -21,6 +22,7 @@ export function ImageUpload({
onChange, onChange,
onCropChange, onCropChange,
}: ImageUploadProps) { }: ImageUploadProps) {
const { t } = useTranslation("common");
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [localPreview, setLocalPreview] = useState<string | null>(null); const [localPreview, setLocalPreview] = useState<string | null>(null);
@@ -39,12 +41,12 @@ export function ImageUpload({
setError(null); setError(null);
if (!ACCEPTED_TYPES.includes(file.type)) { if (!ACCEPTED_TYPES.includes(file.type)) {
setError("Please select a JPG, PNG, or WebP image."); setError(t("imageUpload.invalidType"));
return; return;
} }
if (file.size > MAX_SIZE_BYTES) { if (file.size > MAX_SIZE_BYTES) {
setError("Image must be under 5MB."); setError(t("imageUpload.tooLarge"));
return; return;
} }
@@ -63,7 +65,7 @@ export function ImageUpload({
setShowCropEditor(true); setShowCropEditor(true);
} }
} catch { } catch {
setError("Upload failed. Please try again."); setError(t("imageUpload.uploadFailed"));
setLocalPreview(null); setLocalPreview(null);
} finally { } finally {
setUploading(false); setUploading(false);
@@ -183,7 +185,7 @@ export function ImageUpload({
<path d="M12.5 5.5h3" /> <path d="M12.5 5.5h3" />
</svg> </svg>
<span className="mt-2 text-sm text-gray-400 group-hover:text-gray-500 transition-colors"> <span className="mt-2 text-sm text-gray-400 group-hover:text-gray-500 transition-colors">
Click to add photo {t("imageUpload.clickToAdd")}
</span> </span>
</div> </div>
)} )}

View File

@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import type { CandidateDelta } from "../hooks/useImpactDeltas"; import type { CandidateDelta } from "../hooks/useImpactDeltas";
interface ImpactDeltaBadgeProps { interface ImpactDeltaBadgeProps {
@@ -11,6 +12,8 @@ export function ImpactDeltaBadge({
type, type,
formatFn, formatFn,
}: ImpactDeltaBadgeProps) { }: ImpactDeltaBadgeProps) {
const { t } = useTranslation("setups");
if (!delta || delta.mode === "none") return null; if (!delta || delta.mode === "none") return null;
const value = type === "weight" ? delta.weightDelta : delta.priceDelta; const value = type === "weight" ? delta.weightDelta : delta.priceDelta;
@@ -28,7 +31,7 @@ export function ImpactDeltaBadge({
<span className="text-xs text-green-600"> <span className="text-xs text-green-600">
+{formatFn(value)} +{formatFn(value)}
{delta.mode === "add" && ( {delta.mode === "add" && (
<span className="ml-0.5 text-green-500">(add)</span> <span className="ml-0.5 text-green-500">({t("impact.adding")})</span>
)} )}
</span> </span>
); );

View File

@@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useCategories } from "../hooks/useCategories"; import { useCategories } from "../hooks/useCategories";
import { useThreads } from "../hooks/useThreads"; import { useThreads } from "../hooks/useThreads";
import { useUIStore } from "../stores/uiStore"; import { useUIStore } from "../stores/uiStore";
@@ -7,6 +8,7 @@ import { CreateThreadModal } from "./CreateThreadModal";
import { ThreadCard } from "./ThreadCard"; import { ThreadCard } from "./ThreadCard";
export function PlanningView() { export function PlanningView() {
const { t } = useTranslation(["threads", "common"]);
const [activeTab, setActiveTab] = useState<"active" | "resolved">("active"); const [activeTab, setActiveTab] = useState<"active" | "resolved">("active");
const [categoryFilter, setCategoryFilter] = useState<number | null>(null); const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
@@ -41,7 +43,7 @@ export function PlanningView() {
{/* Header row */} {/* Header row */}
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900"> <h2 className="text-lg font-semibold text-gray-900">
Planning Threads {t("threads:planning.title")}
</h2> </h2>
<button <button
type="button" type="button"
@@ -62,7 +64,7 @@ export function PlanningView() {
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
New Thread {t("threads:create.title")}
</button> </button>
</div> </div>
@@ -79,7 +81,7 @@ export function PlanningView() {
: "text-gray-600 hover:bg-gray-200" : "text-gray-600 hover:bg-gray-200"
}`} }`}
> >
Active {t("threads:status.active")}
</button> </button>
<button <button
type="button" type="button"
@@ -90,7 +92,7 @@ export function PlanningView() {
: "text-gray-600 hover:bg-gray-200" : "text-gray-600 hover:bg-gray-200"
}`} }`}
> >
Resolved {t("threads:status.resolved")}
</button> </button>
</div> </div>
@@ -107,7 +109,7 @@ export function PlanningView() {
<div className="py-16"> <div className="py-16">
<div className="max-w-lg mx-auto text-center"> <div className="max-w-lg mx-auto text-center">
<h2 className="text-xl font-semibold text-gray-900 mb-8"> <h2 className="text-xl font-semibold text-gray-900 mb-8">
Plan your next purchase {t("threads:planning.emptyTitle")}
</h2> </h2>
<div className="space-y-6 text-left mb-10"> <div className="space-y-6 text-left mb-10">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
@@ -115,9 +117,9 @@ export function PlanningView() {
1 1
</div> </div>
<div> <div>
<p className="font-medium text-gray-900">Create a thread</p> <p className="font-medium text-gray-900">{t("threads:planning.step1Title")}</p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Start a research thread for gear you're considering {t("threads:planning.step1Description")}
</p> </p>
</div> </div>
</div> </div>
@@ -126,9 +128,9 @@ export function PlanningView() {
2 2
</div> </div>
<div> <div>
<p className="font-medium text-gray-900">Add candidates</p> <p className="font-medium text-gray-900">{t("threads:planning.step2Title")}</p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Add products you're comparing with prices and weights {t("threads:planning.step2Description")}
</p> </p>
</div> </div>
</div> </div>
@@ -137,9 +139,9 @@ export function PlanningView() {
3 3
</div> </div>
<div> <div>
<p className="font-medium text-gray-900">Pick a winner</p> <p className="font-medium text-gray-900">{t("threads:planning.step3Title")}</p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
Resolve the thread and the winner joins your collection {t("threads:planning.step3Description")}
</p> </p>
</div> </div>
</div> </div>
@@ -163,13 +165,13 @@ export function PlanningView() {
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
Create your first thread {t("threads:planning.createFirst")}
</button> </button>
</div> </div>
</div> </div>
) : filteredThreads.length === 0 ? ( ) : filteredThreads.length === 0 ? (
<div className="py-12 text-center"> <div className="py-12 text-center">
<p className="text-sm text-gray-500">No threads found</p> <p className="text-sm text-gray-500">{t("threads:empty.noThreads")}</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">

View File

@@ -1,4 +1,5 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
interface PublicSetupCardProps { interface PublicSetupCardProps {
setup: { setup: {
@@ -11,6 +12,7 @@ interface PublicSetupCardProps {
} }
export function PublicSetupCard({ setup }: PublicSetupCardProps) { export function PublicSetupCard({ setup }: PublicSetupCardProps) {
const { t } = useTranslation("setups");
const formattedDate = new Date(setup.createdAt).toLocaleDateString( const formattedDate = new Date(setup.createdAt).toLocaleDateString(
undefined, undefined,
{ {
@@ -30,13 +32,13 @@ export function PublicSetupCard({ setup }: PublicSetupCardProps) {
{setup.name} {setup.name}
</h3> </h3>
<p className="text-xs text-gray-400 mt-0.5"> <p className="text-xs text-gray-400 mt-0.5">
by {setup.creatorName || "Anonymous"} {t("card.by", { name: setup.creatorName || t("card.anonymous") })}
</p> </p>
<div className="flex items-center justify-between mt-2"> <div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{setup.itemCount != null && setup.itemCount > 0 && ( {setup.itemCount != null && setup.itemCount > 0 && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
{setup.itemCount} {setup.itemCount === 1 ? "item" : "items"} {t("card.items", { count: setup.itemCount })}
</span> </span>
)} )}
</div> </div>

View File

@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { useSetups } from "../hooks/useSetups"; import { useSetups } from "../hooks/useSetups";
import { useUIStore } from "../stores/uiStore"; import { useUIStore } from "../stores/uiStore";
@@ -8,6 +9,7 @@ interface SetupImpactSelectorProps {
export function SetupImpactSelector({ export function SetupImpactSelector({
threadStatus, threadStatus,
}: SetupImpactSelectorProps) { }: SetupImpactSelectorProps) {
const { t } = useTranslation("setups");
const { data: setups } = useSetups(); const { data: setups } = useSetups();
const selectedSetupId = useUIStore((s) => s.selectedSetupId); const selectedSetupId = useUIStore((s) => s.selectedSetupId);
const setSelectedSetupId = useUIStore((s) => s.setSelectedSetupId); const setSelectedSetupId = useUIStore((s) => s.setSelectedSetupId);
@@ -23,7 +25,7 @@ export function SetupImpactSelector({
} }
className="border border-gray-200 rounded-lg text-sm px-3 py-1.5 text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-gray-300" className="border border-gray-200 rounded-lg text-sm px-3 py-1.5 text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-gray-300"
> >
<option value="">Compare with setup...</option> <option value="">{t("impact.compareWith")}</option>
{setups.map((setup) => ( {setups.map((setup) => (
<option key={setup.id} value={setup.id}> <option key={setup.id} value={setup.id}>
{setup.name} {setup.name}

View File

@@ -1,4 +1,5 @@
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { useFormatters } from "../hooks/useFormatters"; import { useFormatters } from "../hooks/useFormatters";
import { LucideIcon } from "../lib/iconData"; import { LucideIcon } from "../lib/iconData";
@@ -31,6 +32,7 @@ export function ThreadCard({
categoryIcon, categoryIcon,
}: ThreadCardProps) { }: ThreadCardProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation("threads");
const { price } = useFormatters(); const { price } = useFormatters();
function formatPriceRange( function formatPriceRange(
@@ -62,7 +64,7 @@ export function ThreadCard({
<h3 className="text-sm font-semibold text-gray-900 truncate">{name}</h3> <h3 className="text-sm font-semibold text-gray-900 truncate">{name}</h3>
{isResolved && ( {isResolved && (
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 shrink-0"> <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 shrink-0">
Resolved {t("status.resolved")}
</span> </span>
)} )}
</div> </div>
@@ -76,7 +78,7 @@ export function ThreadCard({
{categoryName} {categoryName}
</span> </span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-500"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-500">
{candidateCount} {candidateCount === 1 ? "candidate" : "candidates"} {t("card.candidates", { count: candidateCount })}
</span> </span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
{formatDate(createdAt)} {formatDate(createdAt)}

View File

@@ -1,3 +1,5 @@
import { useTranslation } from "react-i18next";
type TabKey = "gear" | "planning" | "setups"; type TabKey = "gear" | "planning" | "setups";
interface CollectionTabsProps { interface CollectionTabsProps {
@@ -5,13 +7,14 @@ interface CollectionTabsProps {
onChange: (tab: TabKey) => void; onChange: (tab: TabKey) => void;
} }
const tabs = [
{ key: "gear" as const, label: "My Gear" },
{ key: "planning" as const, label: "Planning" },
{ key: "setups" as const, label: "Setups" },
];
export function CollectionTabs({ active, onChange }: CollectionTabsProps) { export function CollectionTabs({ active, onChange }: CollectionTabsProps) {
const { t } = useTranslation("collection");
const tabs = [
{ key: "gear" as const, label: t("gear") },
{ key: "planning" as const, label: t("planning") },
{ key: "setups" as const, label: t("tabs.setups") },
];
return ( return (
<div className="flex border-b border-gray-200"> <div className="flex border-b border-gray-200">
{tabs.map((tab) => ( {tabs.map((tab) => (

View File

@@ -1,4 +1,5 @@
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { useAuth } from "../hooks/useAuth"; import { useAuth } from "../hooks/useAuth";
import { LucideIcon } from "../lib/iconData"; import { LucideIcon } from "../lib/iconData";
import { UserMenu } from "./UserMenu"; import { UserMenu } from "./UserMenu";
@@ -9,6 +10,7 @@ interface TotalsBarProps {
} }
export function TotalsBar({ title = "GearBox", linkTo }: TotalsBarProps) { export function TotalsBar({ title = "GearBox", linkTo }: TotalsBarProps) {
const { t } = useTranslation("common");
const { data: auth } = useAuth(); const { data: auth } = useAuth();
const isAuthenticated = !!auth?.user; const isAuthenticated = !!auth?.user;
@@ -43,7 +45,7 @@ export function TotalsBar({ title = "GearBox", linkTo }: TotalsBarProps) {
to="/login" to="/login"
className="text-xs text-gray-500 hover:text-gray-700 transition-colors" className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
> >
Sign in {t("auth.signIn")}
</Link> </Link>
)} )}
</div> </div>

View File

@@ -27,5 +27,17 @@
"light": "Leicht", "light": "Leicht",
"medium": "Mittel", "medium": "Mittel",
"heavy": "Schwer" "heavy": "Schwer"
},
"tabs": {
"setups": "Setups"
},
"totals": {
"totalWeight": "Gesamtgewicht",
"totalCost": "Gesamtkosten"
},
"classificationBadge": {
"base": "Basisgewicht",
"worn": "Getragen",
"consumable": "Verbrauchsmaterial"
} }
} }

View File

@@ -82,6 +82,12 @@
"recentlyAdded": "Kürzlich hinzugefügt", "recentlyAdded": "Kürzlich hinzugefügt",
"trendingCategories": "Trend-Kategorien" "trendingCategories": "Trend-Kategorien"
}, },
"imageUpload": {
"clickToAdd": "Zum Hinzufügen klicken",
"invalidType": "Bitte wählen Sie ein JPG-, PNG- oder WebP-Bild aus.",
"tooLarge": "Das Bild muss kleiner als 5 MB sein.",
"uploadFailed": "Upload fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"profile": { "profile": {
"title": "Profil", "title": "Profil",
"account": "Konto", "account": "Konto",

View File

@@ -6,10 +6,12 @@
"description": "Erstellen Sie ein Setup, um Ausruestung fuer bestimmte Reisen oder Aktivitaeten zu organisieren." "description": "Erstellen Sie ein Setup, um Ausruestung fuer bestimmte Reisen oder Aktivitaeten zu organisieren."
}, },
"card": { "card": {
"items": "{{count}} Gegenstaende", "items": "{{count}} Gegenstände",
"items_one": "{{count}} Gegenstand", "items_one": "{{count}} Gegenstand",
"weight": "Gewicht", "weight": "Gewicht",
"price": "Preis" "price": "Preis",
"by": "von {{name}}",
"anonymous": "Anonym"
}, },
"share": { "share": {
"title": "Setup teilen", "title": "Setup teilen",
@@ -37,7 +39,8 @@
}, },
"impact": { "impact": {
"title": "Auswirkungsvorschau", "title": "Auswirkungsvorschau",
"adding": "Hinzufuegen", "adding": "Hinzufügen",
"removing": "Entfernen" "removing": "Entfernen",
"compareWith": "Mit Setup vergleichen..."
} }
} }

View File

@@ -39,7 +39,22 @@
"message": "<bold>{{name}}</bold> als Gewinner waehlen? Der Gegenstand wird Ihrer Sammlung hinzugefuegt und der Thread archiviert." "message": "<bold>{{name}}</bold> als Gewinner waehlen? Der Gegenstand wird Ihrer Sammlung hinzugefuegt und der Thread archiviert."
}, },
"empty": { "empty": {
"noThreads": "Noch keine Recherche-Threads", "noThreads": "Keine Threads gefunden",
"noCandidates": "Noch keine Kandidaten" "noCandidates": "Noch keine Kandidaten"
},
"card": {
"candidates": "{{count}} Kandidaten",
"candidates_one": "{{count}} Kandidat"
},
"planning": {
"title": "Planungs-Threads",
"emptyTitle": "Nächsten Kauf planen",
"createFirst": "Ersten Thread erstellen",
"step1Title": "Thread erstellen",
"step1Description": "Starten Sie einen Recherche-Thread für Ausrüstung, die Sie in Betracht ziehen",
"step2Title": "Kandidaten hinzufügen",
"step2Description": "Fügen Sie Produkte hinzu, die Sie mit Preisen und Gewichten vergleichen",
"step3Title": "Gewinner wählen",
"step3Description": "Thread abschließen und der Gewinner kommt in Ihre Sammlung"
} }
} }

View File

@@ -27,5 +27,17 @@
"light": "Light", "light": "Light",
"medium": "Medium", "medium": "Medium",
"heavy": "Heavy" "heavy": "Heavy"
},
"tabs": {
"setups": "Setups"
},
"totals": {
"totalWeight": "Total Weight",
"totalCost": "Total Cost"
},
"classificationBadge": {
"base": "Base Weight",
"worn": "Worn",
"consumable": "Consumable"
} }
} }

View File

@@ -82,6 +82,12 @@
"recentlyAdded": "Recently Added", "recentlyAdded": "Recently Added",
"trendingCategories": "Trending Categories" "trendingCategories": "Trending Categories"
}, },
"imageUpload": {
"clickToAdd": "Click to add photo",
"invalidType": "Please select a JPG, PNG, or WebP image.",
"tooLarge": "Image must be under 5MB.",
"uploadFailed": "Upload failed. Please try again."
},
"profile": { "profile": {
"title": "Profile", "title": "Profile",
"account": "Account", "account": "Account",

View File

@@ -9,7 +9,9 @@
"items": "{{count}} items", "items": "{{count}} items",
"items_one": "{{count}} item", "items_one": "{{count}} item",
"weight": "Weight", "weight": "Weight",
"price": "Price" "price": "Price",
"by": "by {{name}}",
"anonymous": "Anonymous"
}, },
"share": { "share": {
"title": "Share Setup", "title": "Share Setup",
@@ -38,6 +40,7 @@
"impact": { "impact": {
"title": "Impact Preview", "title": "Impact Preview",
"adding": "Adding", "adding": "Adding",
"removing": "Removing" "removing": "Removing",
"compareWith": "Compare with setup..."
} }
} }

View File

@@ -39,7 +39,22 @@
"message": "Pick <bold>{{name}}</bold> as the winner? This will add it to your collection and archive the thread." "message": "Pick <bold>{{name}}</bold> as the winner? This will add it to your collection and archive the thread."
}, },
"empty": { "empty": {
"noThreads": "No research threads yet", "noThreads": "No threads found",
"noCandidates": "No candidates yet" "noCandidates": "No candidates yet"
},
"card": {
"candidates": "{{count}} candidates",
"candidates_one": "{{count}} candidate"
},
"planning": {
"title": "Planning Threads",
"emptyTitle": "Plan your next purchase",
"createFirst": "Create your first thread",
"step1Title": "Create a thread",
"step1Description": "Start a research thread for gear you're considering",
"step2Title": "Add candidates",
"step2Description": "Add products you're comparing with prices and weights",
"step3Title": "Pick a winner",
"step3Description": "Resolve the thread and the winner joins your collection"
} }
} }