feat(34-06): wire useTranslation into routes and settings currency suggestion

- Add useTranslation to routes/index.tsx: home section headings use t()
- Add useTranslation to routes/profile.tsx: all profile/security/danger zone strings use t()
- Wire currency suggestion banner in settings.tsx with t() interpolation
- Wire showConversions section title/description in settings.tsx
- Add home and profile keys to en/common.json
- Add currency.suggestion, currency.switch, showConversions to en/settings.json
- Add corresponding German translations with proper umlauts to de/common.json and de/settings.json
This commit is contained in:
2026-04-17 20:21:54 +02:00
parent b21ba0d97b
commit 755c0ab89f
7 changed files with 137 additions and 48 deletions

View File

@@ -73,8 +73,42 @@
"totalSpent": "Gesamtausgaben" "totalSpent": "Gesamtausgaben"
}, },
"filter": { "filter": {
"showing": "{{filtered}} von {{total}} Gegenstaenden", "showing": "{{filtered}} von {{total}} Gegenständen",
"searchItems": "Gegenstaende suchen...", "searchItems": "Gegenstände suchen...",
"allCategories": "Alle Kategorien" "allCategories": "Alle Kategorien"
},
"home": {
"popularSetups": "Beliebte Setups",
"recentlyAdded": "Kürzlich hinzugefügt",
"trendingCategories": "Trend-Kategorien"
},
"profile": {
"title": "Profil",
"account": "Konto",
"accountInfo": "Ihre Kontoinformationen",
"email": "E-Mail",
"noEmail": "Keine E-Mail-Adresse hinterlegt",
"change": "Ändern",
"newEmailPlaceholder": "Neue E-Mail-Adresse",
"updating": "Wird aktualisiert...",
"updateEmail": "E-Mail aktualisieren",
"emailUpdated": "E-Mail aktualisiert",
"memberSince": "Mitglied seit",
"security": "Sicherheit",
"managePassword": "Passwort verwalten",
"currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort",
"password": "Passwort",
"confirmPassword": "Passwort bestätigen",
"passwordRequirements": "Das Passwort muss mindestens 8 Zeichen lang sein und Groß- und Kleinbuchstaben sowie eine Zahl enthalten.",
"passwordUpdated": "Passwort aktualisiert",
"changingPassword": "Wird geändert...",
"changePassword": "Passwort ändern",
"setPassword": "Passwort festlegen",
"dangerZone": "Gefahrenzone",
"dangerZoneDescription": "Konto und alle persönlichen Daten löschen. Öffentliche Setups werden als \"Gelöschter Benutzer\" angezeigt.",
"deleteAccount": "Konto löschen",
"deleteConfirmMessage": "Diese Aktion ist dauerhaft. Geben Sie LÖSCHEN ein, um zu bestätigen.",
"deleteConfirmPlaceholder": "Geben Sie LÖSCHEN ein, um zu bestätigen"
} }
} }

View File

@@ -9,8 +9,14 @@
"description": "Waehlen Sie die Einheit fuer die Gewichtsanzeige in der App" "description": "Waehlen Sie die Einheit fuer die Gewichtsanzeige in der App"
}, },
"currency": { "currency": {
"title": "Waehrung", "title": "Währung",
"description": "Aendert das angezeigte Waehrungssymbol. Werte werden nicht umgerechnet." "description": "Ändert das angezeigte Währungssymbol. Werte werden nicht umgerechnet.",
"suggestion": "Basierend auf Ihrer Region empfehlen wir {{symbol}} ({{code}})",
"switch": "Wechseln"
},
"showConversions": {
"title": "Umgerechnete Preise anzeigen",
"description": "Näherungsweise Umrechnungen anzeigen, wenn kein lokaler Preis verfügbar ist"
}, },
"apiKeys": { "apiKeys": {
"title": "API-Schluessel", "title": "API-Schluessel",

View File

@@ -76,5 +76,39 @@
"showing": "Showing {{filtered}} of {{total}} items", "showing": "Showing {{filtered}} of {{total}} items",
"searchItems": "Search items...", "searchItems": "Search items...",
"allCategories": "All Categories" "allCategories": "All Categories"
},
"home": {
"popularSetups": "Popular Setups",
"recentlyAdded": "Recently Added",
"trendingCategories": "Trending Categories"
},
"profile": {
"title": "Profile",
"account": "Account",
"accountInfo": "Your account information",
"email": "Email",
"noEmail": "No email on file",
"change": "Change",
"newEmailPlaceholder": "New email address",
"updating": "Updating...",
"updateEmail": "Update Email",
"emailUpdated": "Email updated",
"memberSince": "Member since",
"security": "Security",
"managePassword": "Manage your password",
"currentPassword": "Current Password",
"newPassword": "New Password",
"password": "Password",
"confirmPassword": "Confirm Password",
"passwordRequirements": "Password must be at least 8 characters with uppercase, lowercase, and a number.",
"passwordUpdated": "Password updated",
"changingPassword": "Changing...",
"changePassword": "Change Password",
"setPassword": "Set Password",
"dangerZone": "Danger Zone",
"dangerZoneDescription": "Delete your account and all personal data. Public setups will be attributed to \"Deleted User\".",
"deleteAccount": "Delete Account",
"deleteConfirmMessage": "This action is permanent. Type DELETE to confirm.",
"deleteConfirmPlaceholder": "Type DELETE to confirm"
} }
} }

View File

@@ -10,7 +10,13 @@
}, },
"currency": { "currency": {
"title": "Currency", "title": "Currency",
"description": "Changes the currency symbol displayed. This does not convert values." "description": "Changes the currency symbol displayed. This does not convert values.",
"suggestion": "Based on your region, we suggest {{symbol}} ({{code}})",
"switch": "Switch"
},
"showConversions": {
"title": "Show Converted Prices",
"description": "Display approximate conversions when local price is not available"
}, },
"apiKeys": { "apiKeys": {
"title": "API Keys", "title": "API Keys",

View File

@@ -1,4 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { GlobalItemCard } from "../components/GlobalItemCard"; import { GlobalItemCard } from "../components/GlobalItemCard";
import { PublicSetupCard } from "../components/PublicSetupCard"; import { PublicSetupCard } from "../components/PublicSetupCard";
import { import {
@@ -22,6 +23,7 @@ function LandingPage() {
} }
function PopularSetupsSection() { function PopularSetupsSection() {
const { t } = useTranslation("common");
const { data, isLoading } = useDiscoverySetups(6); const { data, isLoading } = useDiscoverySetups(6);
const setups = data?.items ?? []; const setups = data?.items ?? [];
@@ -30,7 +32,7 @@ function PopularSetupsSection() {
return ( return (
<section className="mb-12"> <section className="mb-12">
<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">Popular Setups</h2> <h2 className="text-lg font-semibold text-gray-900">{t("home.popularSetups")}</h2>
</div> </div>
{isLoading ? ( {isLoading ? (
<SectionSkeleton count={6} aspect="none" /> <SectionSkeleton count={6} aspect="none" />
@@ -46,6 +48,7 @@ function PopularSetupsSection() {
} }
function RecentItemsSection() { function RecentItemsSection() {
const { t } = useTranslation("common");
const { data, isLoading } = useDiscoveryItems(8); const { data, isLoading } = useDiscoveryItems(8);
const items = data?.items ?? []; const items = data?.items ?? [];
@@ -54,7 +57,7 @@ function RecentItemsSection() {
return ( return (
<section className="mb-12"> <section className="mb-12">
<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">Recently Added</h2> <h2 className="text-lg font-semibold text-gray-900">{t("home.recentlyAdded")}</h2>
</div> </div>
{isLoading ? ( {isLoading ? (
<SectionSkeleton count={8} aspect="[4/3]" /> <SectionSkeleton count={8} aspect="[4/3]" />
@@ -79,6 +82,7 @@ function RecentItemsSection() {
} }
function TrendingCategoriesSection() { function TrendingCategoriesSection() {
const { t } = useTranslation("common");
const { data, isLoading } = useDiscoveryCategories(12); const { data, isLoading } = useDiscoveryCategories(12);
const categories = data ?? []; const categories = data ?? [];
@@ -88,7 +92,7 @@ function TrendingCategoriesSection() {
<section className="mb-12"> <section className="mb-12">
<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">
Trending Categories {t("home.trendingCategories")}
</h2> </h2>
</div> </div>
{isLoading ? ( {isLoading ? (

View File

@@ -1,5 +1,6 @@
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ProfileSection } from "../components/ProfileSection"; import { ProfileSection } from "../components/ProfileSection";
import { import {
useChangeEmail, useChangeEmail,
@@ -14,6 +15,7 @@ export const Route = createFileRoute("/profile")({
}); });
function ProfilePage() { function ProfilePage() {
const { t } = useTranslation("common");
const { data: auth, isLoading } = useAuth(); const { data: auth, isLoading } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -34,9 +36,9 @@ function ProfilePage() {
to="/" to="/"
className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block" className="text-sm text-gray-500 hover:text-gray-700 mb-2 inline-block"
> >
&larr; Back &larr; {t("actions.back")}
</Link> </Link>
<h1 className="text-xl font-semibold text-gray-900">Profile</h1> <h1 className="text-xl font-semibold text-gray-900">{t("profile.title")}</h1>
</div> </div>
{/* Section 1: Profile Info (D-02) */} {/* Section 1: Profile Info (D-02) */}
@@ -74,6 +76,7 @@ function AccountInfoSection({
email?: string; email?: string;
createdAt?: string; createdAt?: string;
}) { }) {
const { t } = useTranslation("common");
const changeEmail = useChangeEmail(); const changeEmail = useChangeEmail();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [newEmail, setNewEmail] = useState(""); const [newEmail, setNewEmail] = useState("");
@@ -83,7 +86,7 @@ function AccountInfoSection({
} | null>(null); } | null>(null);
const memberSince = createdAt const memberSince = createdAt
? new Intl.DateTimeFormat("en-US", { ? new Intl.DateTimeFormat(undefined, {
month: "long", month: "long",
year: "numeric", year: "numeric",
}).format(new Date(createdAt)) }).format(new Date(createdAt))
@@ -94,7 +97,7 @@ function AccountInfoSection({
setMessage(null); setMessage(null);
try { try {
await changeEmail.mutateAsync({ newEmail: newEmail.trim() }); await changeEmail.mutateAsync({ newEmail: newEmail.trim() });
setMessage({ type: "success", text: "Email updated" }); setMessage({ type: "success", text: t("profile.emailUpdated") });
setEditing(false); setEditing(false);
setNewEmail(""); setNewEmail("");
} catch (err) { } catch (err) {
@@ -105,16 +108,16 @@ function AccountInfoSection({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="text-sm font-medium text-gray-900">Account</h3> <h3 className="text-sm font-medium text-gray-900">{t("profile.account")}</h3>
<p className="text-xs text-gray-500 mt-0.5">Your account information</p> <p className="text-xs text-gray-500 mt-0.5">{t("profile.accountInfo")}</p>
</div> </div>
{/* Email row */} {/* Email row */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<span className="text-sm font-medium text-gray-700">Email</span> <span className="text-sm font-medium text-gray-700">{t("profile.email")}</span>
<span className="text-sm text-gray-900 ml-3"> <span className="text-sm text-gray-900 ml-3">
{email || "No email on file"} {email || t("profile.noEmail")}
</span> </span>
</div> </div>
{!editing && ( {!editing && (
@@ -123,7 +126,7 @@ function AccountInfoSection({
onClick={() => setEditing(true)} onClick={() => setEditing(true)}
className="text-sm text-gray-600 hover:text-gray-800 transition-colors" className="text-sm text-gray-600 hover:text-gray-800 transition-colors"
> >
Change {t("profile.change")}
</button> </button>
)} )}
</div> </div>
@@ -133,7 +136,7 @@ function AccountInfoSection({
<form onSubmit={handleEmailChange} className="flex gap-2"> <form onSubmit={handleEmailChange} className="flex gap-2">
<input <input
type="email" type="email"
placeholder="New email address" placeholder={t("profile.newEmailPlaceholder")}
value={newEmail} value={newEmail}
onChange={(e) => setNewEmail(e.target.value)} onChange={(e) => setNewEmail(e.target.value)}
required required
@@ -144,7 +147,7 @@ function AccountInfoSection({
disabled={changeEmail.isPending} disabled={changeEmail.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" 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"
> >
{changeEmail.isPending ? "Updating..." : "Update Email"} {changeEmail.isPending ? t("profile.updating") : t("profile.updateEmail")}
</button> </button>
<button <button
type="button" type="button"
@@ -155,7 +158,7 @@ function AccountInfoSection({
}} }}
className="px-3 py-2 text-sm text-gray-500 hover:text-gray-700 transition-colors" className="px-3 py-2 text-sm text-gray-500 hover:text-gray-700 transition-colors"
> >
Cancel {t("actions.cancel")}
</button> </button>
</form> </form>
)} )}
@@ -172,7 +175,7 @@ function AccountInfoSection({
{memberSince && ( {memberSince && (
<div> <div>
<span className="text-sm font-medium text-gray-700"> <span className="text-sm font-medium text-gray-700">
Member since {t("profile.memberSince")}
</span> </span>
<span className="text-sm text-gray-500 ml-3">{memberSince}</span> <span className="text-sm text-gray-500 ml-3">{memberSince}</span>
</div> </div>
@@ -184,6 +187,7 @@ function AccountInfoSection({
// ── Security Section ──────────────────────────────────────────────── // ── Security Section ────────────────────────────────────────────────
function SecuritySection() { function SecuritySection() {
const { t } = useTranslation("common");
const { data: hasPasswordData } = useHasPassword(); const { data: hasPasswordData } = useHasPassword();
const changePassword = useChangePassword(); const changePassword = useChangePassword();
const hasPassword = hasPasswordData?.hasPassword ?? true; const hasPassword = hasPasswordData?.hasPassword ?? true;
@@ -215,7 +219,7 @@ function SecuritySection() {
currentPassword: hasPassword ? currentPassword : "", currentPassword: hasPassword ? currentPassword : "",
newPassword, newPassword,
}); });
setMessage({ type: "success", text: "Password updated" }); setMessage({ type: "success", text: t("profile.passwordUpdated") });
// Per T-28-08: clear password fields on success // Per T-28-08: clear password fields on success
setCurrentPassword(""); setCurrentPassword("");
setNewPassword(""); setNewPassword("");
@@ -228,8 +232,8 @@ function SecuritySection() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="text-sm font-medium text-gray-900">Security</h3> <h3 className="text-sm font-medium text-gray-900">{t("profile.security")}</h3>
<p className="text-xs text-gray-500 mt-0.5">Manage your password</p> <p className="text-xs text-gray-500 mt-0.5">{t("profile.managePassword")}</p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-3"> <form onSubmit={handleSubmit} className="space-y-3">
@@ -239,7 +243,7 @@ function SecuritySection() {
htmlFor="currentPassword" htmlFor="currentPassword"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Current Password {t("profile.currentPassword")}
</label> </label>
<input <input
id="currentPassword" id="currentPassword"
@@ -256,7 +260,7 @@ function SecuritySection() {
htmlFor="newPassword" htmlFor="newPassword"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
{hasPassword ? "New Password" : "Password"} {hasPassword ? t("profile.newPassword") : t("profile.password")}
</label> </label>
<input <input
id="newPassword" id="newPassword"
@@ -272,7 +276,7 @@ function SecuritySection() {
htmlFor="confirmPassword" htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 mb-1" className="block text-sm font-medium text-gray-700 mb-1"
> >
Confirm Password {t("profile.confirmPassword")}
</label> </label>
<input <input
id="confirmPassword" id="confirmPassword"
@@ -284,8 +288,7 @@ function SecuritySection() {
</div> </div>
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
Password must be at least 8 characters with uppercase, lowercase, and {t("profile.passwordRequirements")}
a number.
</p> </p>
{message && ( {message && (
@@ -302,10 +305,10 @@ function SecuritySection() {
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" 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"
> >
{changePassword.isPending {changePassword.isPending
? "Changing..." ? t("profile.changingPassword")
: hasPassword : hasPassword
? "Change Password" ? t("profile.changePassword")
: "Set Password"} : t("profile.setPassword")}
</button> </button>
</form> </form>
</div> </div>
@@ -315,6 +318,7 @@ function SecuritySection() {
// ── Danger Zone Section ───────────────────────────────────────────── // ── Danger Zone Section ─────────────────────────────────────────────
function DangerZoneSection() { function DangerZoneSection() {
const { t } = useTranslation("common");
const deleteAccount = useDeleteAccount(); const deleteAccount = useDeleteAccount();
const [showConfirm, setShowConfirm] = useState(false); const [showConfirm, setShowConfirm] = useState(false);
const [confirmation, setConfirmation] = useState(""); const [confirmation, setConfirmation] = useState("");
@@ -331,10 +335,9 @@ function DangerZoneSection() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h3 className="text-sm font-medium text-gray-900">Danger Zone</h3> <h3 className="text-sm font-medium text-gray-900">{t("profile.dangerZone")}</h3>
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-gray-500 mt-0.5">
Delete your account and all personal data. Public setups will be {t("profile.dangerZoneDescription")}
attributed to &quot;Deleted User&quot;.
</p> </p>
</div> </div>
@@ -344,16 +347,16 @@ function DangerZoneSection() {
onClick={() => setShowConfirm(true)} onClick={() => setShowConfirm(true)}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg transition-colors"
> >
Delete Account {t("profile.deleteAccount")}
</button> </button>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">
This action is permanent. Type DELETE to confirm. {t("profile.deleteConfirmMessage")}
</p> </p>
<input <input
type="text" type="text"
placeholder="Type DELETE to confirm" placeholder={t("profile.deleteConfirmPlaceholder")}
value={confirmation} value={confirmation}
onChange={(e) => setConfirmation(e.target.value)} onChange={(e) => setConfirmation(e.target.value)}
className="w-full px-3 py-2 border border-red-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-200" className="w-full px-3 py-2 border border-red-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-200"
@@ -367,7 +370,7 @@ function DangerZoneSection() {
}} }}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors" 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")}
</button> </button>
<button <button
type="button" type="button"
@@ -375,7 +378,7 @@ function DangerZoneSection() {
disabled={confirmation !== "DELETE" || deleteAccount.isPending} disabled={confirmation !== "DELETE" || deleteAccount.isPending}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors" className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 rounded-lg transition-colors"
> >
{deleteAccount.isPending ? "Deleting..." : "Delete Account"} {deleteAccount.isPending ? t("actions.deleting") : t("profile.deleteAccount")}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -295,10 +295,12 @@ function SettingsPage() {
{showSuggestion && ( {showSuggestion && (
<div className="bg-blue-50 border border-blue-100 rounded-xl px-4 py-3 mb-4 flex items-center gap-3"> <div className="bg-blue-50 border border-blue-100 rounded-xl px-4 py-3 mb-4 flex items-center gap-3">
<span className="text-sm text-blue-700 flex-1"> <span className="text-sm text-blue-700 flex-1">
Based on your region, we suggest{" "} {t("currency.suggestion", {
{CURRENCIES.find((c) => c.value === suggestedCurrency)?.label ?? symbol:
suggestedCurrency}{" "} CURRENCIES.find((c) => c.value === suggestedCurrency)?.label ??
({suggestedCurrency}) suggestedCurrency,
code: suggestedCurrency,
})}
</span> </span>
<button <button
type="button" type="button"
@@ -311,13 +313,13 @@ function SettingsPage() {
}} }}
className="text-sm font-medium text-blue-700 hover:text-blue-800 underline shrink-0" className="text-sm font-medium text-blue-700 hover:text-blue-800 underline shrink-0"
> >
Switch {t("currency.switch")}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setSuggestionDismissed(true)} onClick={() => setSuggestionDismissed(true)}
className="p-1 text-blue-400 hover:text-blue-600 rounded shrink-0" className="p-1 text-blue-400 hover:text-blue-600 rounded shrink-0"
aria-label="Dismiss" aria-label={t("common:actions.dismiss")}
> >
<LucideIcon name="x" size={16} /> <LucideIcon name="x" size={16} />
</button> </button>
@@ -428,10 +430,10 @@ function SettingsPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-sm font-medium text-gray-900"> <h3 className="text-sm font-medium text-gray-900">
Show Converted Prices {t("showConversions.title")}
</h3> </h3>
<p className="text-xs text-gray-500 mt-0.5"> <p className="text-xs text-gray-500 mt-0.5">
Display approximate conversions when local price is not available {t("showConversions.description")}
</p> </p>
</div> </div>
<button <button