feat(i18n): translate pet-profile area (Edit Profile, Health, stats, dropdowns, ReportSheet, BlockDialog)

The entire /pets area (My Pets grid, pet profile page, followers/following
lists, the profile options dropdown, ReportSheet, BlockDialog) had zero
i18n despite ProfileTabs right next to it being fully translated — user
reported still seeing English after switching the app to German.

Adds 7 new message namespaces (PetsPage, PetProfile, FollowersPage,
FollowingPage, ProfileActions, ReportSheet, BlockDialog) with full EN/DE
parity, plus bio/adoptionStory placeholder keys to the existing PetForm
namespace (two placeholders were hardcoded English template literals
despite the rest of that form already being translated).

Server Components (pets/page.tsx, pets/[petId]/page.tsx,
followers/following pages) use getTranslations from next-intl/server
instead of the useTranslations client hook.
This commit is contained in:
admin committed 2026-07-23 15:02:37 +02:00
1 parent 4ba6bbc0c3
commit b7719397e6
11 files changed
+176 -47

No files matched your search

+57
View File
@@ -69,7 +69,11 @@
"selectSpeciesFirst": "Wähle zuerst eine Tierart aus",
"noBreedsFound": "Keine Rassen gefunden.",
"bio": "Bio",
"bioPlaceholder": "Erzähl uns von {petName}…",
"bioPlaceholderFallback": "Erzähl uns von deinem Tier…",
"adoptionStory": "Adoptionsgeschichte",
"adoptionStoryPlaceholder": "Wie kam {petName} zu deiner Familie?",
"adoptionStoryPlaceholderFallback": "Wie kam dein Tier zu deiner Familie?",
"birthday": "Geburtstag",
"birthdayHint": "Für Geburtstags-Erinnerungen",
"adoptedAt": "Adoptionsdatum",
@@ -425,5 +429,58 @@
},
"RepostCard": {
"pawedBackBy": "Paw-Back von"
},
"PetsPage": {
"title": "Meine Tiere",
"addPet": "Tier anlegen",
"emptyTitle": "Füge dein erstes Tier hinzu",
"emptyDesc": "Gib deinem Tier ein eigenes Profil auf PawFeed."
},
"PetProfile": {
"editProfile": "Profil bearbeiten",
"health": "Gesundheit",
"managedBy": "Verwaltet von @{username}",
"statsPosts": "Posts",
"statsFollowers": "Follower",
"statsFollowing": "Folgt",
"adoptionStory": "Adoptionsgeschichte"
},
"FollowersPage": {
"title": "{count} Follower",
"empty": "{petName} hat noch keine Follower."
},
"FollowingPage": {
"title": "Folgt {count}",
"empty": "{petName} folgt noch niemandem."
},
"ProfileActions": {
"message": "Nachricht",
"cannotStartConversation": "Unterhaltung konnte nicht gestartet werden.",
"moreOptions": "Weitere Optionen",
"reportProfile": "Profil melden",
"blockPet": "{petName} blockieren"
},
"ReportSheet": {
"title": "Melden",
"reasonQuestion": "Warum meldest du das?",
"reasonSpam": "Spam",
"reasonHarassment": "Belästigung oder Mobbing",
"reasonInappropriate": "Unangemessene Inhalte",
"reasonMisinformation": "Falsche oder irreführende Informationen",
"reasonOther": "Etwas anderes",
"notesPlaceholder": "Details hinzufügen (optional)",
"charactersRemaining": "Noch {count} Zeichen",
"submit": "Meldung absenden",
"cancel": "Nicht melden",
"successToast": "Meldung übermittelt. Wir prüfen sie in Kürze.",
"errorToast": "Deine Meldung konnte nicht übermittelt werden. Bitte versuch es erneut."
},
"BlockDialog": {
"title": "{petName} blockieren?",
"description": "{petName}s Posts und Profil werden für dich ausgeblendet. Niemand erfährt, dass du blockiert hast.",
"cancel": "Nicht blockieren",
"confirm": "Blockieren",
"successToast": "Du hast {petName} blockiert",
"errorToast": "Tier konnte nicht blockiert werden. Bitte versuch es erneut."
}
}
+57
View File
@@ -69,7 +69,11 @@
"selectSpeciesFirst": "Select a species first",
"noBreedsFound": "No breeds found.",
"bio": "Bio",
"bioPlaceholder": "Tell us about {petName}…",
"bioPlaceholderFallback": "Tell us about your pet…",
"adoptionStory": "Adoption story",
"adoptionStoryPlaceholder": "How did {petName} join your family?",
"adoptionStoryPlaceholderFallback": "How did your pet join your family?",
"birthday": "Birthday",
"birthdayHint": "For birthday reminders",
"adoptedAt": "Adoption date",
@@ -425,5 +429,58 @@
},
"RepostCard": {
"pawedBackBy": "Pawed-back by"
},
"PetsPage": {
"title": "My Pets",
"addPet": "Add Pet",
"emptyTitle": "Add your first pet",
"emptyDesc": "Give your pet their own profile on PawFeed."
},
"PetProfile": {
"editProfile": "Edit profile",
"health": "Health",
"managedBy": "Managed by @{username}",
"statsPosts": "posts",
"statsFollowers": "followers",
"statsFollowing": "following",
"adoptionStory": "Adoption story"
},
"FollowersPage": {
"title": "{count} Followers",
"empty": "{petName} doesn't have any followers yet."
},
"FollowingPage": {
"title": "Following {count}",
"empty": "{petName} isn't following anyone yet."
},
"ProfileActions": {
"message": "Message",
"cannotStartConversation": "Cannot start conversation.",
"moreOptions": "More options",
"reportProfile": "Report profile",
"blockPet": "Block {petName}"
},
"ReportSheet": {
"title": "Report",
"reasonQuestion": "Why are you reporting this?",
"reasonSpam": "Spam",
"reasonHarassment": "Harassment or bullying",
"reasonInappropriate": "Inappropriate content",
"reasonMisinformation": "False or misleading information",
"reasonOther": "Something else",
"notesPlaceholder": "Add details (optional)",
"charactersRemaining": "{count} characters remaining",
"submit": "Submit Report",
"cancel": "Don't report",
"successToast": "Report submitted. We'll review it shortly.",
"errorToast": "Couldn't submit your report. Please try again."
},
"BlockDialog": {
"title": "Block {petName}?",
"description": "{petName}'s posts and profile will be hidden from you. They won't know you blocked them.",
"cancel": "Don't block",
"confirm": "Block",
"successToast": "You've blocked {petName}",
"errorToast": "Couldn't block this pet. Please try again."
}
}
+6 -4
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { PetCard } from "@/components/pet/PetCard";
import { Button } from "@/components/ui/button";
import { useActivePet } from "@/context/ActivePetContext";
@@ -24,21 +25,22 @@ interface PetGridProps {
}
export function PetGrid({ pets }: PetGridProps) {
const t = useTranslations("PetsPage");
const { activePetId, setActivePet } = useActivePet();
const router = useRouter();
if (pets.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border-2 border-dashed border-muted p-12 text-center">
<p className="text-base font-semibold">Add your first pet</p>
<p className="text-base font-semibold">{t("emptyTitle")}</p>
<p className="text-sm text-muted-foreground max-w-sm">
Give your pet their own profile on PawFeed.
{t("emptyDesc")}
</p>
<Button
onClick={() => router.push("/onboarding/pet")}
className="mt-2"
>
Add Pet
{t("addPet")}
</Button>
</div>
);
@@ -63,7 +65,7 @@ export function PetGrid({ pets }: PetGridProps) {
className="flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-muted p-6 text-sm text-muted-foreground hover:border-muted-foreground/50 transition-colors min-h-[180px]"
>
<span className="text-2xl font-light">+</span>
<span>Add Pet</span>
<span>{t("addPet")}</span>
</Link>
</div>
);
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { MoreHorizontal, MessageCircle, Loader2 } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -40,6 +41,7 @@ interface ProfileActionsProps {
}
export function ProfileActions({ petId, petName, ownerId }: ProfileActionsProps) {
const t = useTranslations("ProfileActions");
const { activePetId } = useActivePet();
const router = useRouter();
const trpc = useTRPC();
@@ -49,7 +51,7 @@ export function ProfileActions({ petId, petName, ownerId }: ProfileActionsProps)
const getOrCreateMutation = useMutation(
trpc.conversations.getOrCreate.mutationOptions({
onSuccess: (conv) => router.push(`/messages/${conv.id}`),
onError: (err) => toast.error(err.message ?? "Cannot start conversation."),
onError: (err) => toast.error(err.message ?? t("cannotStartConversation")),
})
);
@@ -88,7 +90,7 @@ export function ProfileActions({ petId, petName, ownerId }: ProfileActionsProps)
) : (
<MessageCircle className="h-4 w-4" />
)}
Message
{t("message")}
</Button>
)}
@@ -98,7 +100,7 @@ export function ProfileActions({ petId, petName, ownerId }: ProfileActionsProps)
<DropdownMenu>
<DropdownMenuTrigger
className="h-[44px] w-[44px] flex items-center justify-center rounded-md border border-border hover:bg-muted transition-colors cursor-pointer"
aria-label="More options"
aria-label={t("moreOptions")}
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
@@ -107,14 +109,14 @@ export function ProfileActions({ petId, petName, ownerId }: ProfileActionsProps)
onClick={() => setReportOpen(true)}
className="cursor-pointer"
>
Report profile
{t("reportProfile")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setBlockOpen(true)}
className="cursor-pointer text-destructive focus:text-destructive"
>
Block {petName}
{t("blockPet", { petName })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { createTRPCCaller } from "@/trpc/server";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FollowersList } from "@/app/(app)/pets/[petId]/followers/FollowersList";
@@ -20,6 +21,7 @@ interface PageProps {
export default async function FollowersPage({ params }: PageProps) {
const { petId } = await params;
const t = await getTranslations("FollowersPage");
const trpc = await createTRPCCaller();
let pet;
@@ -36,12 +38,12 @@ export default async function FollowersPage({ params }: PageProps) {
return (
<div className="max-w-[480px] mx-auto px-4 py-6">
<h1 className="text-xl font-semibold mb-6">
{followers.length} Followers
{t("title", { count: followers.length })}
</h1>
{followers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
{pet.name} doesn&apos;t have any followers yet.
{t("empty", { petName: pet.name })}
</p>
) : (
<ScrollArea className="h-[calc(100vh-120px)]">
@@ -1,4 +1,5 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { createTRPCCaller } from "@/trpc/server";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FollowingList } from "@/app/(app)/pets/[petId]/following/FollowingList";
@@ -20,6 +21,7 @@ interface PageProps {
export default async function FollowingPage({ params }: PageProps) {
const { petId } = await params;
const t = await getTranslations("FollowingPage");
const trpc = await createTRPCCaller();
let pet;
@@ -36,12 +38,12 @@ export default async function FollowingPage({ params }: PageProps) {
return (
<div className="max-w-[480px] mx-auto px-4 py-6">
<h1 className="text-xl font-semibold mb-6">
Following {following.length}
{t("title", { count: following.length })}
</h1>
{following.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
{pet.name} isn&apos;t following anyone yet.
{t("empty", { petName: pet.name })}
</p>
) : (
<ScrollArea className="h-[calc(100vh-120px)]">
+9 -7
View File
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { currentUser } from "@clerk/nextjs/server";
import { getTranslations } from "next-intl/server";
import { createTRPCCaller } from "@/trpc/server";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
@@ -28,6 +29,7 @@ function getInitials(name: string): string {
export default async function PetProfilePage({ params }: PageProps) {
const { petId } = await params;
const t = await getTranslations("PetProfile");
const trpc = await createTRPCCaller();
let pet;
@@ -74,7 +76,7 @@ export default async function PetProfilePage({ params }: PageProps) {
{/* Owner attribution — only shown on own pet */}
{isOwnPet && ownerUsername && (
<p className="text-xs text-muted-foreground">
Managed by @{ownerUsername}
{t("managedBy", { username: ownerUsername })}
</p>
)}
@@ -85,13 +87,13 @@ export default async function PetProfilePage({ params }: PageProps) {
href={`/pets/${pet.id}/edit`}
className={buttonVariants({ variant: "outline" })}
>
Edit profile
{t("editProfile")}
</Link>
<Link
href={`/pets/${pet.id}/health`}
className={buttonVariants({ variant: "outline" })}
>
Health
{t("health")}
</Link>
</div>
)}
@@ -101,21 +103,21 @@ export default async function PetProfilePage({ params }: PageProps) {
<div className="flex justify-center gap-8 mb-6">
<div className="flex flex-col items-center">
<span className="text-xl font-semibold">0</span>
<span className="text-xs text-muted-foreground">posts</span>
<span className="text-xs text-muted-foreground">{t("statsPosts")}</span>
</div>
<Link
href={`/pets/${pet.id}/followers`}
className="flex flex-col items-center hover:opacity-80 transition-opacity"
>
<span className="text-xl font-semibold">{followers.length}</span>
<span className="text-xs text-muted-foreground">followers</span>
<span className="text-xs text-muted-foreground">{t("statsFollowers")}</span>
</Link>
<Link
href={`/pets/${pet.id}/following`}
className="flex flex-col items-center hover:opacity-80 transition-opacity"
>
<span className="text-xl font-semibold">{following.length}</span>
<span className="text-xs text-muted-foreground">following</span>
<span className="text-xs text-muted-foreground">{t("statsFollowing")}</span>
</Link>
</div>
@@ -135,7 +137,7 @@ export default async function PetProfilePage({ params }: PageProps) {
<>
<Separator className="mb-6" />
<section className="mb-6">
<h2 className="text-base font-semibold mb-2">Adoption story</h2>
<h2 className="text-base font-semibold mb-2">{t("adoptionStory")}</h2>
<p className="text-sm leading-relaxed">{pet.adoptionStory}</p>
</section>
</>
+4 -2
View File
@@ -1,4 +1,5 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { createTRPCCaller } from "@/trpc/server";
import { buttonVariants } from "@/components/ui/button";
import { PetGrid } from "./PetGrid";
@@ -15,6 +16,7 @@ export default async function PetsPage({
}: {
searchParams: Promise<{ deleteAccount?: string }>;
}) {
const t = await getTranslations("PetsPage");
const trpc = await createTRPCCaller();
const pets = await trpc.pets.list();
const { deleteAccount } = await searchParams;
@@ -22,10 +24,10 @@ export default async function PetsPage({
return (
<div className="container max-w-3xl mx-auto px-6 py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold">My Pets</h1>
<h1 className="text-xl font-semibold">{t("title")}</h1>
{/* base-nova Button doesn't support asChild — use Link with button styles */}
<Link href="/onboarding/pet" className={buttonVariants({ variant: "default" })}>
Add Pet
{t("addPet")}
</Link>
</div>
+2 -2
View File
@@ -312,7 +312,7 @@ export function PetForm({
<Textarea
id="bio"
maxLength={150}
placeholder={`Tell us about ${petName || "your pet"}…`}
placeholder={petName ? t("bioPlaceholder", { petName }) : t("bioPlaceholderFallback")}
aria-invalid={!!errors.bio}
{...register("bio")}
/>
@@ -330,7 +330,7 @@ export function PetForm({
<Textarea
id="adoption-story"
maxLength={500}
placeholder={`How did ${petName || "your pet"} join your family?`}
placeholder={petName ? t("adoptionStoryPlaceholder", { petName }) : t("adoptionStoryPlaceholderFallback")}
aria-invalid={!!errors.adoptionStory}
{...register("adoptionStory")}
/>
+8 -7
View File
@@ -1,6 +1,7 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import {
AlertDialog,
@@ -43,19 +44,20 @@ export function BlockDialog({
blockedPetId,
blockedPetName,
}: BlockDialogProps) {
const t = useTranslations("BlockDialog");
const trpc = useTRPC();
const queryClient = useQueryClient();
const blockMutation = useMutation(
trpc.blocks.create.mutationOptions({
onSuccess: () => {
toast.success(`You've blocked ${blockedPetName}`);
toast.success(t("successToast", { petName: blockedPetName }));
onOpenChange(false);
// Invalidate any cached block lists so feed hydration picks up the new block
queryClient.invalidateQueries({ queryKey: ["follows"] });
},
onError: () => {
toast.error("Couldn't block this pet. Please try again.");
toast.error(t("errorToast"));
},
})
);
@@ -68,20 +70,19 @@ export function BlockDialog({
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Block {blockedPetName}?</AlertDialogTitle>
<AlertDialogTitle>{t("title", { petName: blockedPetName })}</AlertDialogTitle>
<AlertDialogDescription>
{blockedPetName}&apos;s posts and profile will be hidden from you.
They won&apos;t know you blocked them.
{t("description", { petName: blockedPetName })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Don&apos;t block</AlertDialogCancel>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleBlock}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={blockMutation.isPending}
>
Block
{t("confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+18 -16
View File
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { ReportReason } from "@prisma/client";
import {
@@ -41,14 +42,6 @@ interface ReportSheetProps {
targetPetId?: string;
}
const REPORT_REASONS: { value: ReportReason; label: string }[] = [
{ value: ReportReason.SPAM, label: "Spam" },
{ value: ReportReason.HARASSMENT, label: "Harassment or bullying" },
{ value: ReportReason.INAPPROPRIATE, label: "Inappropriate content" },
{ value: ReportReason.MISINFORMATION, label: "False or misleading information" },
{ value: ReportReason.OTHER, label: "Something else" },
];
export function ReportSheet({
open,
onOpenChange,
@@ -56,21 +49,30 @@ export function ReportSheet({
targetPostId,
targetPetId,
}: ReportSheetProps) {
const t = useTranslations("ReportSheet");
const trpc = useTRPC();
const [selectedReason, setSelectedReason] = useState<ReportReason | "">("");
const [notes, setNotes] = useState("");
const REPORT_REASONS: { value: ReportReason; label: string }[] = [
{ value: ReportReason.SPAM, label: t("reasonSpam") },
{ value: ReportReason.HARASSMENT, label: t("reasonHarassment") },
{ value: ReportReason.INAPPROPRIATE, label: t("reasonInappropriate") },
{ value: ReportReason.MISINFORMATION, label: t("reasonMisinformation") },
{ value: ReportReason.OTHER, label: t("reasonOther") },
];
const reportMutation = useMutation(
trpc.reports.create.mutationOptions({
onSuccess: () => {
toast.success("Report submitted. We'll review it shortly.");
toast.success(t("successToast"));
onOpenChange(false);
// Reset form state
setSelectedReason("");
setNotes("");
},
onError: () => {
toast.error("Couldn't submit your report. Please try again.");
toast.error(t("errorToast"));
},
})
);
@@ -97,13 +99,13 @@ export function ReportSheet({
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="sm:side-right sm:max-w-[400px]">
<SheetHeader>
<SheetTitle className="text-xl font-semibold">Report</SheetTitle>
<SheetTitle className="text-xl font-semibold">{t("title")}</SheetTitle>
</SheetHeader>
<div className="mt-4 space-y-4">
<div>
<p className="text-sm font-medium mb-3">
Why are you reporting this?
{t("reasonQuestion")}
</p>
<RadioGroup
value={selectedReason}
@@ -126,7 +128,7 @@ export function ReportSheet({
<div>
<Textarea
placeholder="Add details (optional)"
placeholder={t("notesPlaceholder")}
value={notes}
onChange={(e) => setNotes(e.target.value)}
maxLength={500}
@@ -135,7 +137,7 @@ export function ReportSheet({
/>
{notes.length > 450 && (
<p className="text-xs text-muted-foreground mt-1 text-right">
{500 - notes.length} characters remaining
{t("charactersRemaining", { count: 500 - notes.length })}
</p>
)}
</div>
@@ -146,14 +148,14 @@ export function ReportSheet({
disabled={!selectedReason || reportMutation.isPending}
className="w-full min-h-[44px] bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Submit Report
{t("submit")}
</Button>
<button
type="button"
onClick={handleCancel}
className="w-full text-sm text-muted-foreground hover:text-foreground py-2"
>
Don&apos;t report
{t("cancel")}
</button>
</div>
</div>