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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user