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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useCategories } from "../hooks/useCategories";
import { useThreads } from "../hooks/useThreads";
import { useUIStore } from "../stores/uiStore";
@@ -7,6 +8,7 @@ import { CreateThreadModal } from "./CreateThreadModal";
import { ThreadCard } from "./ThreadCard";
export function PlanningView() {
const { t } = useTranslation(["threads", "common"]);
const [activeTab, setActiveTab] = useState<"active" | "resolved">("active");
const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
@@ -41,7 +43,7 @@ export function PlanningView() {
{/* Header row */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">
Planning Threads
{t("threads:planning.title")}
</h2>
<button
type="button"
@@ -62,7 +64,7 @@ export function PlanningView() {
d="M12 4v16m8-8H4"
/>
</svg>
New Thread
{t("threads:create.title")}
</button>
</div>
@@ -79,7 +81,7 @@ export function PlanningView() {
: "text-gray-600 hover:bg-gray-200"
}`}
>
Active
{t("threads:status.active")}
</button>
<button
type="button"
@@ -90,7 +92,7 @@ export function PlanningView() {
: "text-gray-600 hover:bg-gray-200"
}`}
>
Resolved
{t("threads:status.resolved")}
</button>
</div>
@@ -107,7 +109,7 @@ export function PlanningView() {
<div className="py-16">
<div className="max-w-lg mx-auto text-center">
<h2 className="text-xl font-semibold text-gray-900 mb-8">
Plan your next purchase
{t("threads:planning.emptyTitle")}
</h2>
<div className="space-y-6 text-left mb-10">
<div className="flex items-start gap-4">
@@ -115,9 +117,9 @@ export function PlanningView() {
1
</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">
Start a research thread for gear you're considering
{t("threads:planning.step1Description")}
</p>
</div>
</div>
@@ -126,9 +128,9 @@ export function PlanningView() {
2
</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">
Add products you're comparing with prices and weights
{t("threads:planning.step2Description")}
</p>
</div>
</div>
@@ -137,9 +139,9 @@ export function PlanningView() {
3
</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">
Resolve the thread and the winner joins your collection
{t("threads:planning.step3Description")}
</p>
</div>
</div>
@@ -163,13 +165,13 @@ export function PlanningView() {
d="M12 4v16m8-8H4"
/>
</svg>
Create your first thread
{t("threads:planning.createFirst")}
</button>
</div>
</div>
) : filteredThreads.length === 0 ? (
<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 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 { useTranslation } from "react-i18next";
interface PublicSetupCardProps {
setup: {
@@ -11,6 +12,7 @@ interface PublicSetupCardProps {
}
export function PublicSetupCard({ setup }: PublicSetupCardProps) {
const { t } = useTranslation("setups");
const formattedDate = new Date(setup.createdAt).toLocaleDateString(
undefined,
{
@@ -30,13 +32,13 @@ export function PublicSetupCard({ setup }: PublicSetupCardProps) {
{setup.name}
</h3>
<p className="text-xs text-gray-400 mt-0.5">
by {setup.creatorName || "Anonymous"}
{t("card.by", { name: setup.creatorName || t("card.anonymous") })}
</p>
<div className="flex items-center justify-between mt-2">
<div className="flex items-center gap-2">
{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">
{setup.itemCount} {setup.itemCount === 1 ? "item" : "items"}
{t("card.items", { count: setup.itemCount })}
</span>
)}
</div>

View File

@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { useSetups } from "../hooks/useSetups";
import { useUIStore } from "../stores/uiStore";
@@ -8,6 +9,7 @@ interface SetupImpactSelectorProps {
export function SetupImpactSelector({
threadStatus,
}: SetupImpactSelectorProps) {
const { t } = useTranslation("setups");
const { data: setups } = useSetups();
const selectedSetupId = useUIStore((s) => s.selectedSetupId);
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"
>
<option value="">Compare with setup...</option>
<option value="">{t("impact.compareWith")}</option>
{setups.map((setup) => (
<option key={setup.id} value={setup.id}>
{setup.name}

View File

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

View File

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

View File

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

View File

@@ -27,5 +27,17 @@
"light": "Leicht",
"medium": "Mittel",
"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",
"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": {
"title": "Profil",
"account": "Konto",

View File

@@ -6,10 +6,12 @@
"description": "Erstellen Sie ein Setup, um Ausruestung fuer bestimmte Reisen oder Aktivitaeten zu organisieren."
},
"card": {
"items": "{{count}} Gegenstaende",
"items": "{{count}} Gegenstände",
"items_one": "{{count}} Gegenstand",
"weight": "Gewicht",
"price": "Preis"
"price": "Preis",
"by": "von {{name}}",
"anonymous": "Anonym"
},
"share": {
"title": "Setup teilen",
@@ -37,7 +39,8 @@
},
"impact": {
"title": "Auswirkungsvorschau",
"adding": "Hinzufuegen",
"removing": "Entfernen"
"adding": "Hinzufügen",
"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."
},
"empty": {
"noThreads": "Noch keine Recherche-Threads",
"noThreads": "Keine Threads gefunden",
"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",
"medium": "Medium",
"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",
"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": {
"title": "Profile",
"account": "Account",

View File

@@ -9,7 +9,9 @@
"items": "{{count}} items",
"items_one": "{{count}} item",
"weight": "Weight",
"price": "Price"
"price": "Price",
"by": "by {{name}}",
"anonymous": "Anonymous"
},
"share": {
"title": "Share Setup",
@@ -38,6 +40,7 @@
"impact": {
"title": "Impact Preview",
"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."
},
"empty": {
"noThreads": "No research threads yet",
"noThreads": "No threads found",
"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"
}
}