diff --git a/messages/de.json b/messages/de.json
index 45748f9..92405f8 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -234,7 +234,8 @@
"toastDeleted": "Eintrag gelöscht",
"toastError": "Gewichtseintrag konnte nicht gespeichert werden",
"toastDeleteError": "Eintrag konnte nicht gelöscht werden",
- "toastValidation": "Bitte Datum und gültiges Gewicht eingeben"
+ "toastValidation": "Bitte Datum und gültiges Gewicht eingeben",
+ "chartHistory": "Verlauf"
},
"vet": {
"add": "Besuch hinzufügen",
@@ -295,6 +296,134 @@
"toastDeleted": "Fütterungsinfo entfernt",
"toastError": "Fütterungsinfo konnte nicht gespeichert werden",
"toastDeleteError": "Fütterungsinfo konnte nicht entfernt werden"
+ },
+ "share": {
+ "title": "Health Card teilen — {petName}",
+ "description": "Jeder mit diesem Link kann {petName}s Gesundheitsdaten einsehen — Gewicht, Impfungen und Tierarztbesuche. Kein Login erforderlich.",
+ "validityLabel": "Gültigkeitsdauer",
+ "expiryDays": "{count} Tage",
+ "generate": "Generieren",
+ "validUntil": "Gültig bis",
+ "openPdf": "Als PDF öffnen / drucken",
+ "regenerateTitle": "Link neu generieren",
+ "regenerateDesc": "Erstellt einen neuen Link und macht den aktuellen ungültig. Nützlich zum Widerrufen von Zugriffen.",
+ "toastGenerateError": "Link konnte nicht generiert werden",
+ "toastRegenerateSuccess": "Neuer Link erstellt — alter Link ist jetzt ungültig",
+ "toastRegenerateError": "Link konnte nicht neu generiert werden"
}
+ },
+ "DeleteAccount": {
+ "trigger": "Konto löschen",
+ "warningTitle": "Konto wirklich löschen?",
+ "warningIntro": "Diese Aktion ist unwiderruflich. Folgendes wird dauerhaft gelöscht:",
+ "itemPets": "Alle Tierprofile",
+ "itemPosts": "Alle Posts, Kommentare und Reaktionen",
+ "itemMedia": "Alle Bilder und Videos",
+ "itemMessages": "Alle Chats und Nachrichten",
+ "itemHealth": "Alle Gesundheitsdaten",
+ "itemAccount": "Dein PawFeed-Konto",
+ "cancel": "Abbrechen",
+ "understand": "Ich verstehe, weiter",
+ "confirmTitle": "Bestätigung erforderlich",
+ "confirmIntro": "Gib den Namen eines deiner Haustiere ein, um die Löschung zu bestätigen:",
+ "inputPlaceholder": "Tiername eingeben…",
+ "confirmDelete": "Konto endgültig löschen",
+ "deleting": "Konto wird gelöscht…",
+ "doneTitle": "Konto erfolgreich gelöscht.",
+ "doneRedirect": "Du wirst weitergeleitet…",
+ "unknownError": "Unbekannter Fehler",
+ "genericError": "Etwas ist schiefgelaufen"
+ },
+ "NotificationsPage": {
+ "title": "Benachrichtigungen",
+ "emptyTitle": "Noch keine Benachrichtigungen",
+ "emptyDesc": "Wenn Tiere dir folgen oder auf deine Posts reagieren, siehst du es hier.",
+ "typeFollow": "folgt dir jetzt",
+ "typeReaction": "hat auf deinen Post reagiert",
+ "commentedPrefix": "kommentierte:",
+ "typeCommentNoBody": "hat deinen Post kommentiert",
+ "typeMessage": "hat dir eine Nachricht gesendet",
+ "typeMention": "hat dich in einem Kommentar erwähnt",
+ "typePawBack": "hat deinen Post geteilt (Paw-Back)",
+ "typeBirthday": "🎂 Alles Gute zum Geburtstag! Erstelle einen Jubiläums-Post.",
+ "typeAdoptionDay": "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag."
+ },
+ "PhotoPostForm": {
+ "captionPlaceholder": "Bildunterschrift schreiben…",
+ "captionError": "Bildunterschrift darf höchstens 500 Zeichen haben.",
+ "charactersRemaining": "Noch {count} Zeichen",
+ "shareCta": "Post teilen",
+ "sharing": "Wird geteilt…",
+ "successToast": "Post geteilt",
+ "errorToast": "Dein Post konnte nicht geteilt werden. Bitte versuch es erneut.",
+ "noPetSelected": "Kein aktives Tier ausgewählt.",
+ "noPhoto": "Bitte füge mindestens ein Foto hinzu."
+ },
+ "StoryForm": {
+ "addPhoto": "Foto hinzufügen",
+ "disappearsNote": "Storys verschwinden nach 24 Stunden",
+ "changePhoto": "Foto ändern",
+ "postCta": "Story posten",
+ "posting": "Wird gepostet…",
+ "successToast": "Story gepostet",
+ "errorToast": "Deine Story konnte nicht gepostet werden. Bitte versuch es erneut.",
+ "noPetSelected": "Kein aktives Tier ausgewählt.",
+ "fileTooLarge": "Foto muss unter 10 MB sein.",
+ "invalidType": "Lade ein JPG-, PNG- oder WebP-Bild hoch.",
+ "uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut."
+ },
+ "MilestoneForm": {
+ "questionLabel": "Was ist der Meilenstein?",
+ "selectPlaceholder": "Meilenstein auswählen…",
+ "litterDetailsLabel": "Wurf-Details",
+ "optional": "(optional)",
+ "litterPlaceholder": "z.B. 5 gesunde Kätzchen, geboren am 13. Juni…",
+ "charactersRemaining": "Noch {count} Zeichen",
+ "photoLabel": "Foto",
+ "addPhoto": "Foto hinzufügen",
+ "change": "Ändern",
+ "noteLabel": "Notiz hinzufügen",
+ "notePlaceholder": "Notiz hinzufügen… (optional)",
+ "shareCta": "Meilenstein teilen",
+ "sharing": "Wird geteilt…",
+ "successToast": "Meilenstein geteilt",
+ "errorToast": "Meilenstein konnte nicht geteilt werden. Bitte versuch es erneut.",
+ "fileTooLarge": "Foto muss unter 10 MB sein.",
+ "invalidType": "Lade ein JPG-, PNG- oder WebP-Bild hoch.",
+ "uploadFailed": "Upload fehlgeschlagen — bitte versuch es erneut."
+ },
+ "VideoUploadForm": {
+ "selectVideo": "Video auswählen",
+ "formatHint": "MP4, MOV oder WebM · bis 500 MB · max. 3 Minuten",
+ "selectVideoAria": "Video auswählen",
+ "changeVideo": "Anderes Video wählen",
+ "captionLabel": "Bildunterschrift",
+ "captionPlaceholder": "Bildunterschrift schreiben…",
+ "charactersLeft": "Noch {count}",
+ "uploading": "Wird hochgeladen…",
+ "processingDone": "Hochgeladen — Video wird verarbeitet",
+ "uploadFailedGeneric": "Upload fehlgeschlagen — bitte versuch es erneut",
+ "cancel": "Abbrechen",
+ "postVideo": "Video posten",
+ "invalidType": "Lade ein MP4-, MOV- oder WebM-Video hoch.",
+ "fileTooLarge": "Video muss unter 500 MB sein.",
+ "uploadSuccessToast": "Video hochgeladen — wird im Hintergrund verarbeitet",
+ "uploadFailedToast": "Upload fehlgeschlagen: {msg}"
+ },
+ "StoryViewer": {
+ "closeStory": "Story schließen",
+ "storyAriaLabel": "Story von {petName}",
+ "sheetTitleZero": "Noch keine Aufrufe",
+ "sheetTitleSingular": "Von 1 Tier gesehen",
+ "sheetTitlePlural": "Von {count} Tieren gesehen",
+ "noViewersYet": "Noch niemand hat diese Story angesehen."
+ },
+ "StoryTray": {
+ "storiesAriaLabel": "Storys",
+ "addStory": "Story hinzufügen",
+ "newStoryTitle": "Neue Story"
+ },
+ "RepostCard": {
+ "pawedBackBy": "Paw-Back von"
}
}
diff --git a/messages/en.json b/messages/en.json
index 6603212..f9e86c0 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -234,7 +234,8 @@
"toastDeleted": "Entry deleted",
"toastError": "Failed to save weight entry",
"toastDeleteError": "Failed to delete entry",
- "toastValidation": "Please fill in date and a valid weight"
+ "toastValidation": "Please fill in date and a valid weight",
+ "chartHistory": "History"
},
"vet": {
"add": "Add Visit",
@@ -295,6 +296,134 @@
"toastDeleted": "Feeding info removed",
"toastError": "Could not save feeding info",
"toastDeleteError": "Could not remove feeding info"
+ },
+ "share": {
+ "title": "Share Health Card — {petName}",
+ "description": "Anyone with this link can view {petName}'s health data — weight, vaccines, and vet visits. No login required.",
+ "validityLabel": "Validity period",
+ "expiryDays": "{count} days",
+ "generate": "Generate",
+ "validUntil": "Valid until",
+ "openPdf": "Open as PDF / Print",
+ "regenerateTitle": "Regenerate link",
+ "regenerateDesc": "Creates a new link and invalidates the current one. Useful for revoking access.",
+ "toastGenerateError": "Could not generate link",
+ "toastRegenerateSuccess": "New link created — old link is now invalid",
+ "toastRegenerateError": "Could not regenerate link"
}
+ },
+ "DeleteAccount": {
+ "trigger": "Delete account",
+ "warningTitle": "Delete your account?",
+ "warningIntro": "This action is irreversible. The following will be permanently deleted:",
+ "itemPets": "All pet profiles",
+ "itemPosts": "All posts, comments, and reactions",
+ "itemMedia": "All photos and videos",
+ "itemMessages": "All chats and messages",
+ "itemHealth": "All health records",
+ "itemAccount": "Your PawFeed account",
+ "cancel": "Cancel",
+ "understand": "I understand, continue",
+ "confirmTitle": "Confirmation required",
+ "confirmIntro": "Enter the name of one of your pets to confirm deletion:",
+ "inputPlaceholder": "Enter pet name…",
+ "confirmDelete": "Permanently delete account",
+ "deleting": "Deleting account…",
+ "doneTitle": "Account successfully deleted.",
+ "doneRedirect": "You're being redirected…",
+ "unknownError": "Unknown error",
+ "genericError": "Something went wrong"
+ },
+ "NotificationsPage": {
+ "title": "Notifications",
+ "emptyTitle": "No notifications yet",
+ "emptyDesc": "When pets follow you or react to your posts, you'll see it here.",
+ "typeFollow": "started following you",
+ "typeReaction": "reacted to your post",
+ "commentedPrefix": "commented:",
+ "typeCommentNoBody": "commented on your post",
+ "typeMessage": "sent you a message",
+ "typeMention": "mentioned you in a comment",
+ "typePawBack": "shared your post (Paw-Back)",
+ "typeBirthday": "🎂 Happy Birthday! Create an anniversary post.",
+ "typeAdoptionDay": "🏠 Today is adoption day! Celebrate the anniversary."
+ },
+ "PhotoPostForm": {
+ "captionPlaceholder": "Write a caption…",
+ "captionError": "Caption must be 500 characters or fewer.",
+ "charactersRemaining": "{count} characters remaining",
+ "shareCta": "Share Post",
+ "sharing": "Sharing…",
+ "successToast": "Post shared",
+ "errorToast": "Couldn't share your post. Please try again.",
+ "noPetSelected": "No active pet selected.",
+ "noPhoto": "Please add at least one photo."
+ },
+ "StoryForm": {
+ "addPhoto": "Add a photo",
+ "disappearsNote": "Stories disappear after 24 hours",
+ "changePhoto": "Change photo",
+ "postCta": "Post Story",
+ "posting": "Posting…",
+ "successToast": "Story posted",
+ "errorToast": "Couldn't post your story. Please try again.",
+ "noPetSelected": "No active pet selected.",
+ "fileTooLarge": "Photo must be under 10 MB.",
+ "invalidType": "Upload a JPG, PNG, or WebP image.",
+ "uploadFailed": "Upload failed — please try again."
+ },
+ "MilestoneForm": {
+ "questionLabel": "What's the milestone?",
+ "selectPlaceholder": "Select a milestone…",
+ "litterDetailsLabel": "Litter details",
+ "optional": "(optional)",
+ "litterPlaceholder": "e.g. 5 healthy kittens, born 13 June…",
+ "charactersRemaining": "{count} characters remaining",
+ "photoLabel": "Photo",
+ "addPhoto": "Add a photo",
+ "change": "Change",
+ "noteLabel": "Add a note",
+ "notePlaceholder": "Add a note… (optional)",
+ "shareCta": "Share Milestone",
+ "sharing": "Sharing…",
+ "successToast": "Milestone shared",
+ "errorToast": "Couldn't share this milestone. Please try again.",
+ "fileTooLarge": "Photo must be under 10 MB.",
+ "invalidType": "Upload a JPG, PNG, or WebP image.",
+ "uploadFailed": "Upload failed — please try again."
+ },
+ "VideoUploadForm": {
+ "selectVideo": "Select a video",
+ "formatHint": "MP4, MOV or WebM · up to 500 MB · max 3 minutes",
+ "selectVideoAria": "Select video",
+ "changeVideo": "Choose a different video",
+ "captionLabel": "Caption",
+ "captionPlaceholder": "Write a caption…",
+ "charactersLeft": "{count} left",
+ "uploading": "Uploading…",
+ "processingDone": "Uploaded — video is being processed",
+ "uploadFailedGeneric": "Upload failed — please try again",
+ "cancel": "Cancel",
+ "postVideo": "Post video",
+ "invalidType": "Upload an MP4, MOV, or WebM video.",
+ "fileTooLarge": "Video must be under 500 MB.",
+ "uploadSuccessToast": "Video uploaded — processing in the background",
+ "uploadFailedToast": "Upload failed: {msg}"
+ },
+ "StoryViewer": {
+ "closeStory": "Close story",
+ "storyAriaLabel": "{petName}'s story",
+ "sheetTitleZero": "No views yet",
+ "sheetTitleSingular": "Seen by 1 pet",
+ "sheetTitlePlural": "Seen by {count} pets",
+ "noViewersYet": "No one has viewed this story yet."
+ },
+ "StoryTray": {
+ "storiesAriaLabel": "Stories",
+ "addStory": "Add story",
+ "newStoryTitle": "New Story"
+ },
+ "RepostCard": {
+ "pawedBackBy": "Pawed-back by"
}
}
diff --git a/src/app/(app)/notifications/page.tsx b/src/app/(app)/notifications/page.tsx
index 94c3d57..2b97f44 100644
--- a/src/app/(app)/notifications/page.tsx
+++ b/src/app/(app)/notifications/page.tsx
@@ -34,6 +34,7 @@ export default function NotificationsPage() {
const router = useRouter();
const { activePetId } = useActivePet();
const trt = useTranslations("RelativeTime");
+ const t = useTranslations("NotificationsPage");
const formatRelativeTime = (date: Date | string) => formatRelativeCompact(date, trt);
const { data, isLoading } = useQuery({
@@ -81,7 +82,7 @@ export default function NotificationsPage() {
- Notifications
+ {t("title")}
{isLoading && (
@@ -101,9 +102,9 @@ export default function NotificationsPage() {
{!isLoading && notifications.length === 0 && (
-
No notifications yet
+
{t("emptyTitle")}
- When pets follow you or react to your posts, you'll see it here.
+ {t("emptyDesc")}
)}
@@ -164,22 +165,22 @@ export default function NotificationsPage() {
)}{" "}
- {notif.type === "FOLLOW" && "started following you"}
- {notif.type === "REACTION" && "reacted to your post"}
+ {notif.type === "FOLLOW" && t("typeFollow")}
+ {notif.type === "REACTION" && t("typeReaction")}
{notif.type === "COMMENT" && notif.comment && (
<>
- commented: “
+ {t("commentedPrefix")} “
{notif.comment.body.slice(0, 60)}
{notif.comment.body.length > 60 ? "…" : ""}
”
>
)}
- {notif.type === "COMMENT" && !notif.comment && "commented on your post"}
- {notif.type === "MESSAGE" && "sent you a message"}
- {notif.type === "MENTION" && "mentioned you in a comment"}
- {notif.type === "PAW_BACK" && "shared your post (Paw-Back)"}
- {notif.type === "BIRTHDAY" && "🎂 Alles Gute zum Geburtstag! Erstelle einen Jubiläums-Post."}
- {notif.type === "ADOPTION_DAY" && "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag."}
+ {notif.type === "COMMENT" && !notif.comment && t("typeCommentNoBody")}
+ {notif.type === "MESSAGE" && t("typeMessage")}
+ {notif.type === "MENTION" && t("typeMention")}
+ {notif.type === "PAW_BACK" && t("typePawBack")}
+ {notif.type === "BIRTHDAY" && t("typeBirthday")}
+ {notif.type === "ADOPTION_DAY" && t("typeAdoptionDay")}
diff --git a/src/app/health-card/[token]/page.tsx b/src/app/health-card/[token]/page.tsx
index 6bd70aa..7022f30 100644
--- a/src/app/health-card/[token]/page.tsx
+++ b/src/app/health-card/[token]/page.tsx
@@ -183,7 +183,10 @@ export default async function HealthCardPage({ params, searchParams }: PageProps
{pet.weightLogs.length >= 2 && (
-
+ // This page is entirely hardcoded German (not part of the i18n audit
+ // scope — unlike HealthDashboard/WeightChart's in-app usage), so pass
+ // a fixed locale here rather than the viewer's locale to stay consistent.
+
)}
)}
diff --git a/src/components/account/DeleteAccountDialog.tsx b/src/components/account/DeleteAccountDialog.tsx
index 6fd7674..dbbf6f2 100644
--- a/src/components/account/DeleteAccountDialog.tsx
+++ b/src/components/account/DeleteAccountDialog.tsx
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
+import { useTranslations } from "next-intl";
import { Trash2, TriangleAlert, Loader2, CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -19,6 +20,7 @@ type Props = {
};
export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
+ const t = useTranslations("DeleteAccount");
// Lazy initial state (not an effect) — lets the legal-update banner
// deep-link straight into this dialog via ?deleteAccount=1.
const [open, setOpen] = useState(() => !!defaultOpen);
@@ -56,7 +58,7 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
if (!res.ok) {
const data = (await res.json()) as { error?: string };
- throw new Error(data.error ?? "Unbekannter Fehler");
+ throw new Error(data.error ?? t("unknownError"));
}
setStep("done");
@@ -64,11 +66,20 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
window.location.replace("https://pawfeed.org");
}, 1500);
} catch (err) {
- setError(err instanceof Error ? err.message : "Etwas ist schiefgelaufen");
+ setError(err instanceof Error ? err.message : t("genericError"));
setStep("confirm");
}
};
+ const deleteItems = [
+ t("itemPets"),
+ t("itemPosts"),
+ t("itemMedia"),
+ t("itemMessages"),
+ t("itemHealth"),
+ t("itemAccount"),
+ ];
+
return (
<>
- Konto löschen
+ {t("trigger")}
@@ -86,23 +97,16 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
- Konto wirklich löschen?
+ {t("warningTitle")}
- Diese Aktion ist unwiderruflich . Folgendes wird dauerhaft gelöscht:
+ {t("warningIntro")}
- {[
- "Alle Tierprofile",
- "Alle Posts, Kommentare und Reaktionen",
- "Alle Bilder und Videos",
- "Alle Chats und Nachrichten",
- "Alle Gesundheitsdaten",
- "Dein PawFeed-Konto",
- ].map((item) => (
+ {deleteItems.map((item) => (
✕
{item}
@@ -112,10 +116,10 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
- Abbrechen
+ {t("cancel")}
setStep("confirm")}>
- Ich verstehe, weiter
+ {t("understand")}
>
@@ -126,12 +130,12 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
- Bestätigung erforderlich
+ {t("confirmTitle")}
- Gib den Namen eines deiner Haustiere ein, um die Löschung zu bestätigen:
+ {t("confirmIntro")}
@@ -151,7 +155,7 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
className="mt-3"
value={input}
onChange={(e) => { setInput(e.target.value); setError(null); }}
- placeholder="Tiername eingeben…"
+ placeholder={t("inputPlaceholder")}
autoFocus
/>
@@ -161,14 +165,14 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
- Abbrechen
+ {t("cancel")}
- Konto endgültig löschen
+ {t("confirmDelete")}
>
@@ -177,15 +181,15 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
{step === "loading" && (
-
Konto wird gelöscht…
+
{t("deleting")}
)}
{step === "done" && (
-
Konto erfolgreich gelöscht.
-
Du wirst weitergeleitet…
+
{t("doneTitle")}
+
{t("doneRedirect")}
)}
diff --git a/src/components/feed/RepostCard.tsx b/src/components/feed/RepostCard.tsx
index d627fdf..5da4491 100644
--- a/src/components/feed/RepostCard.tsx
+++ b/src/components/feed/RepostCard.tsx
@@ -2,6 +2,7 @@
import { useState, useEffect } from "react";
import Link from "next/link";
+import { useTranslations } from "next-intl";
import { PawPrint } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
@@ -39,6 +40,7 @@ function RepostCardInner({
post: PostCardPost;
originalPost: NonNullable
["originalPost"];
}) {
+ const t = useTranslations("RepostCard");
const reposterName = post.pet.name;
const origPet = originalPost.pet;
const origAvatarUrl = getAvatarUrl(origPet.avatarKey);
@@ -63,7 +65,7 @@ function RepostCardInner({
- Pawed-back by{" "}
+ {t("pawedBackBy")}{" "}
@{reposterName}
@@ -81,7 +83,7 @@ function RepostCardInner({
- Pawed-back by{" "}
+ {t("pawedBackBy")}{" "}
@{reposterName}
diff --git a/src/components/health/HealthDashboard.tsx b/src/components/health/HealthDashboard.tsx
index 59dabf9..893c23a 100644
--- a/src/components/health/HealthDashboard.tsx
+++ b/src/components/health/HealthDashboard.tsx
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { useTranslations } from "next-intl";
+import { useTranslations, useLocale } from "next-intl";
import { toast } from "sonner";
import { Plus, Trash2, Share2, RefreshCw, Copy, Check, Loader2, AlertTriangle, ExternalLink } from "lucide-react";
import { useTRPC } from "@/trpc/client";
@@ -37,8 +37,8 @@ interface HealthDashboardProps {
petName: string;
}
-function formatDate(d: Date | string) {
- return new Date(d).toLocaleDateString("de-DE", {
+function formatDate(d: Date | string, locale: string) {
+ return new Date(d).toLocaleDateString(locale, {
day: "2-digit",
month: "2-digit",
year: "numeric",
@@ -59,6 +59,7 @@ function isOverdue(date: Date | string): boolean {
function WeightTab({ petId }: { petId: string }) {
const t = useTranslations("Health");
+ const locale = useLocale();
const trpc = useTRPC();
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
@@ -149,7 +150,7 @@ function WeightTab({ petId }: { petId: string }) {
{t("weight.noEntries")}
)}
- {!isLoading && data.length >= 2 && }
+ {!isLoading && data.length >= 2 && }
{data.length > 0 && (
@@ -158,7 +159,7 @@ function WeightTab({ petId }: { petId: string }) {
{formatWeight(entry.weightGrams)}
- {formatDate(entry.date)}{entry.notes ? ` · ${entry.notes}` : ""}
+ {formatDate(entry.date, locale)}{entry.notes ? ` · ${entry.notes}` : ""}
{visit.reason}
- {formatDate(visit.date)}{visit.vetName ? ` · ${visit.vetName}` : ""}
+ {formatDate(visit.date, locale)}{visit.vetName ? ` · ${visit.vetName}` : ""}
{visit.notes && (
{visit.notes}
@@ -308,6 +310,7 @@ function VetVisitsTab({ petId }: { petId: string }) {
function VaccinesTab({ petId }: { petId: string }) {
const t = useTranslations("Health");
+ const locale = useLocale();
const trpc = useTRPC();
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
@@ -416,12 +419,12 @@ function VaccinesTab({ petId }: { petId: string }) {
)}
{vac.nextDueDate && !overdue && (
- {t("vaccines.badgeDue", { date: formatDate(vac.nextDueDate) })}
+ {t("vaccines.badgeDue", { date: formatDate(vac.nextDueDate, locale) })}
)}
- {t("vaccines.textGiven", { date: formatDate(vac.dateGiven) })}{vac.notes ? ` · ${vac.notes}` : ""}
+ {t("vaccines.textGiven", { date: formatDate(vac.dateGiven, locale) })}{vac.notes ? ` · ${vac.notes}` : ""}
void;
}) {
+ const t = useTranslations("Health.share");
+ const locale = useLocale();
const trpc = useTRPC();
const [token, setToken] = useState(null);
const [expiresAt, setExpiresAt] = useState(null);
@@ -470,7 +469,7 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
setToken(data.token);
setExpiresAt(data.expiresAt ? new Date(data.expiresAt) : null);
},
- onError: () => toast.error("Link konnte nicht generiert werden"),
+ onError: () => toast.error(t("toastGenerateError")),
});
const regenerateMutation = useMutation({
@@ -478,9 +477,9 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
onSuccess: (data) => {
setToken(data.token);
setExpiresAt(data.expiresAt ? new Date(data.expiresAt) : null);
- toast.success("Neuer Link erstellt — alter Link ist jetzt ungültig");
+ toast.success(t("toastRegenerateSuccess"));
},
- onError: () => toast.error("Link konnte nicht neu generiert werden"),
+ onError: () => toast.error(t("toastRegenerateError")),
});
const handleOpen = (v: boolean) => {
@@ -514,27 +513,29 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
- Health Card teilen — {petName}
+ {t("title", { petName })}
- Jeder mit diesem Link kann {petName}'s Gesundheitsdaten einsehen — Gewicht, Impfungen und Tierarztbesuche. Kein Login erforderlich.
+ {t("description", { petName })}
-
Gültigkeitsdauer
+
{t("validityLabel")}
setExpiryDays(v as typeof expiryDays)}>
- {EXPIRY_OPTIONS.map((opt) => (
- {opt.label}
+ {EXPIRY_VALUES.map((value) => (
+
+ {t("expiryDays", { count: value })}
+
))}
- {isPending ? : "Generieren"}
+ {isPending ? : t("generate")}
@@ -556,22 +557,22 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
{expiresAt && (
- Gültig bis {formatDate(expiresAt)}
+ {t("validUntil")} {formatDate(expiresAt, locale)}
)}
- Als PDF öffnen / drucken
+ {t("openPdf")}
-
Link neu generieren
+
{t("regenerateTitle")}
- Erstellt einen neuen Link und macht den aktuellen ungültig. Nützlich zum Widerrufen von Zugriffen.
+ {t("regenerateDesc")}
(null);
const sorted = [...data].sort(
@@ -86,7 +88,7 @@ export function WeightChart({ data }: { data: ChartEntry[] }) {
- Verlauf
+ {t("chartHistory")}
{trendLabel}
@@ -195,7 +197,7 @@ export function WeightChart({ data }: { data: ChartEntry[] }) {
{hoveredIdx !== null && (() => {
const p = pts[hoveredIdx];
const weight = formatWeight(p.e.weightGrams);
- const dateStr = formatDate(p.e.date);
+ const dateStr = formatDate(p.e.date, locale);
const tw = 96, th = 40;
let tx = p.x - tw / 2;
tx = Math.max(L, Math.min(tx, W - R - tw));
diff --git a/src/components/post-creation/MilestoneForm.tsx b/src/components/post-creation/MilestoneForm.tsx
index 953246f..a45dd67 100644
--- a/src/components/post-creation/MilestoneForm.tsx
+++ b/src/components/post-creation/MilestoneForm.tsx
@@ -3,6 +3,7 @@
import { useState, useRef, useCallback } from "react";
import { ChevronDown, Loader2, ImagePlus, Camera } from "lucide-react";
import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { MilestoneType } from "@prisma/client";
@@ -36,6 +37,7 @@ type UploadState = "idle" | "uploading" | "ready";
* On success: invalidate feed, close dialog, route to /feed (D-03).
*/
export function MilestoneForm({ onClose }: MilestoneFormProps) {
+ const t = useTranslations("MilestoneForm");
const trpc = useTRPC();
const router = useRouter();
const queryClient = useQueryClient();
@@ -66,10 +68,10 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
queryClient.invalidateQueries({ queryKey: [["trpc", "milestones", "listByPet"]] });
onClose();
router.push("/feed");
- toast.success("Milestone shared");
+ toast.success(t("successToast"));
},
onError: () => {
- toast.error("Couldn't share this milestone. Please try again.");
+ toast.error(t("errorToast"));
},
})
);
@@ -80,11 +82,11 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
setUploadError(null);
if (file.size > MAX_FILE_SIZE) {
- setUploadError("Photo must be under 10 MB.");
+ setUploadError(t("fileTooLarge"));
return;
}
if (!ALLOWED_TYPES.includes(file.type as AllowedContentType)) {
- setUploadError("Upload a JPG, PNG, or WebP image.");
+ setUploadError(t("invalidType"));
return;
}
@@ -114,12 +116,12 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
setUploadedKey(key);
setUploadState("ready");
} catch {
- setUploadError("Upload failed — please try again.");
+ setUploadError(t("uploadFailed"));
setUploadState("idle");
setPreviewUrl(null);
}
},
- [activePetId, getPresignedUrlMutation]
+ [activePetId, getPresignedUrlMutation, t]
);
const handleFileChange = useCallback(
@@ -150,7 +152,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
{/* Type picker */}
- What's the milestone?
+ {t("questionLabel")}
@@ -160,7 +162,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
{selectedMeta.label}
) : (
- Select a milestone…
+ {t("selectPlaceholder")}
)}
@@ -191,10 +193,10 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
{selectedType === MilestoneType.LITTER && (
- Litter details (optional)
+ {t("litterDetailsLabel")} {t("optional")}
@@ -212,7 +214,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
{/* Optional photo */}
- Photo (optional)
+ {t("photoLabel")} {t("optional")}
fileInputRef.current?.click()}
>
-
Add a photo
+
{t("addPhoto")}
) : (
@@ -249,7 +251,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
onClick={() => fileInputRef.current?.click()}
>
- Change
+ {t("change")}
)}
@@ -260,10 +262,10 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
{/* Optional caption */}
- Add a note (optional)
+ {t("noteLabel")} {t("optional")}
@@ -285,10 +287,10 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
{createMutation.isPending ? (
<>
- Sharing…
+ {t("sharing")}
>
) : (
- "Share Milestone"
+ t("shareCta")
)}
diff --git a/src/components/post-creation/PhotoPostForm.tsx b/src/components/post-creation/PhotoPostForm.tsx
index 19b9eda..e5c072a 100644
--- a/src/components/post-creation/PhotoPostForm.tsx
+++ b/src/components/post-creation/PhotoPostForm.tsx
@@ -2,6 +2,7 @@
import { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
@@ -37,6 +38,7 @@ interface PhotoPostFormProps {
}
export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
+ const t = useTranslations("PhotoPostForm");
const trpc = useTRPC();
const router = useRouter();
const queryClient = useQueryClient();
@@ -54,27 +56,27 @@ export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
onClose();
// Navigate to /feed with new post at top (D-03)
router.push("/feed");
- toast.success("Post shared");
+ toast.success(t("successToast"));
},
onError: () => {
- toast.error("Couldn't share your post. Please try again.");
+ toast.error(t("errorToast"));
},
})
);
const handleSubmit = useCallback(() => {
if (!activePetId) {
- toast.error("No active pet selected.");
+ toast.error(t("noPetSelected"));
return;
}
if (imageKeys.length === 0) {
- toast.error("Please add at least one photo.");
+ toast.error(t("noPhoto"));
return;
}
if (caption.length > 500) {
- setCaptionError("Caption must be 500 characters or fewer.");
+ setCaptionError(t("captionError"));
return;
}
@@ -83,12 +85,12 @@ export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
caption: caption.trim() || null,
imageKeys,
});
- }, [activePetId, imageKeys, caption, createMutation]);
+ }, [activePetId, imageKeys, caption, createMutation, t]);
if (!activePetId) {
return (
- No active pet selected.
+ {t("noPetSelected")}
);
}
@@ -104,12 +106,12 @@ export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
{/* Caption textarea */}
{
setCaption(val);
if (val.length > 500) {
- setCaptionError("Caption must be 500 characters or fewer.");
+ setCaptionError(t("captionError"));
} else {
setCaptionError(null);
}
@@ -123,7 +125,7 @@ export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
)}
{caption.length > 450 && !captionError && (
- {500 - caption.length} characters remaining
+ {t("charactersRemaining", { count: 500 - caption.length })}
)}
@@ -138,7 +140,7 @@ export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
}
className="w-full min-h-[44px] bg-orange-500 hover:bg-orange-600 text-white font-medium"
>
- {createMutation.isPending ? "Sharing…" : "Share Post"}
+ {createMutation.isPending ? t("sharing") : t("shareCta")}
);
diff --git a/src/components/stories/StoryForm.tsx b/src/components/stories/StoryForm.tsx
index ca77faa..28345d0 100644
--- a/src/components/stories/StoryForm.tsx
+++ b/src/components/stories/StoryForm.tsx
@@ -2,6 +2,7 @@
import { useState, useRef, useCallback } from "react";
import { Camera, Loader2, ImagePlus } from "lucide-react";
+import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
@@ -29,6 +30,7 @@ type UploadState = "idle" | "uploading" | "ready" | "submitting";
* 4. stories.create({ petId, storageKey }) commits the story
*/
export function StoryForm({ onClose }: StoryFormProps) {
+ const t = useTranslations("StoryForm");
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
@@ -53,11 +55,11 @@ export function StoryForm({ onClose }: StoryFormProps) {
queryClient.invalidateQueries(
trpc.stories.listActive.queryOptions({ petId: activePetId ?? "" })
);
- toast.success("Story posted");
+ toast.success(t("successToast"));
onClose();
},
onError: () => {
- toast.error("Couldn't post your story. Please try again.");
+ toast.error(t("errorToast"));
setUploadState("ready");
},
})
@@ -66,18 +68,18 @@ export function StoryForm({ onClose }: StoryFormProps) {
const handleFile = useCallback(
async (file: File) => {
if (!activePetId) {
- toast.error("No active pet selected.");
+ toast.error(t("noPetSelected"));
return;
}
setErrorMessage(null);
if (file.size > MAX_FILE_SIZE) {
- setErrorMessage("Photo must be under 10 MB.");
+ setErrorMessage(t("fileTooLarge"));
return;
}
if (!ALLOWED_TYPES.includes(file.type as AllowedContentType)) {
- setErrorMessage("Upload a JPG, PNG, or WebP image.");
+ setErrorMessage(t("invalidType"));
return;
}
@@ -110,12 +112,12 @@ export function StoryForm({ onClose }: StoryFormProps) {
setUploadedKey(key);
setUploadState("ready");
} catch {
- setErrorMessage("Upload failed — please try again.");
+ setErrorMessage(t("uploadFailed"));
setUploadState("idle");
setPreviewUrl(null);
}
},
- [activePetId, getPresignedUrlMutation]
+ [activePetId, getPresignedUrlMutation, t]
);
const handleFileChange = useCallback(
@@ -147,7 +149,7 @@ export function StoryForm({ onClose }: StoryFormProps) {
if ((e.key === "Enter" || e.key === " ") && uploadState === "idle")
fileInputRef.current?.click();
}}
- aria-label="Add a photo"
+ aria-label={t("addPhoto")}
>
-
Add a photo
+
{t("addPhoto")}
- Stories disappear after 24 hours
+ {t("disappearsNote")}
>
@@ -195,7 +197,7 @@ export function StoryForm({ onClose }: StoryFormProps) {
}}
>
- Change photo
+ {t("changePhoto")}
)}
>
@@ -207,7 +209,7 @@ export function StoryForm({ onClose }: StoryFormProps) {
)}
- Stories disappear after 24 hours
+ {t("disappearsNote")}
- Posting…
+ {t("posting")}
>
) : (
- "Post Story"
+ t("postCta")
)}
diff --git a/src/components/stories/StoryTray.tsx b/src/components/stories/StoryTray.tsx
index 45a56bf..1e2c147 100644
--- a/src/components/stories/StoryTray.tsx
+++ b/src/components/stories/StoryTray.tsx
@@ -2,6 +2,7 @@
import { useState } from "react";
import { PlusCircle } from "lucide-react";
+import { useTranslations } from "next-intl";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
@@ -29,6 +30,7 @@ const TRAY_ITEM_SIZE = TRAY_AVATAR_SIZE + RING_PAD * 2; // total height of a Paw
* each wrapped in PawRing. Tapping a pet opens the StoryViewer.
*/
export function StoryTray() {
+ const t = useTranslations("StoryTray");
const trpc = useTRPC();
const { activePetId } = useActivePet();
@@ -53,7 +55,7 @@ export function StoryTray() {
{/* Add story item — wrapper matches PawRing container height so circles align */}
@@ -65,13 +67,13 @@ export function StoryTray() {
className="flex items-center justify-center rounded-full bg-orange-500 hover:bg-orange-600 transition-colors"
style={{ width: TRAY_AVATAR_SIZE, height: TRAY_AVATAR_SIZE }}
onClick={() => setAddStoryOpen(true)}
- aria-label="Add story"
+ aria-label={t("addStory")}
>
- Add story
+ {t("addStory")}
@@ -124,7 +126,7 @@ export function StoryTray() {
- New Story
+ {t("newStoryTitle")}
setAddStoryOpen(false)} />
diff --git a/src/components/stories/StoryViewer.tsx b/src/components/stories/StoryViewer.tsx
index 2d91b35..71be96e 100644
--- a/src/components/stories/StoryViewer.tsx
+++ b/src/components/stories/StoryViewer.tsx
@@ -2,6 +2,7 @@
import { useState, useEffect, useCallback } from "react";
import { X, Eye } from "lucide-react";
+import { useTranslations } from "next-intl";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
@@ -39,6 +40,7 @@ export function StoryViewer({
open,
onOpenChange,
}: StoryViewerProps) {
+ const t = useTranslations("StoryViewer");
const trpc = useTRPC();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
@@ -129,7 +131,7 @@ export function StoryViewer({
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
role="dialog"
aria-modal="true"
- aria-label={`${petName}'s story`}
+ aria-label={t("storyAriaLabel", { petName })}
>
{/* Progress bars */}
@@ -156,7 +158,7 @@ export function StoryViewer({
// eslint-disable-next-line @next/next/no-img-element
)}
@@ -180,7 +182,7 @@ export function StoryViewer({
@@ -190,7 +192,11 @@ export function StoryViewer({
setViewerSheetOpen(true)}
- aria-label={`Seen by ${viewers.length} pets`}
+ aria-label={
+ viewers.length === 1
+ ? t("sheetTitleSingular")
+ : t("sheetTitlePlural", { count: viewers.length })
+ }
>
{viewers.length}
@@ -204,14 +210,16 @@ export function StoryViewer({
- {viewers.length > 0
- ? `Seen by ${viewers.length} ${viewers.length === 1 ? "pet" : "pets"}`
- : "No views yet"}
+ {viewers.length === 0
+ ? t("sheetTitleZero")
+ : viewers.length === 1
+ ? t("sheetTitleSingular")
+ : t("sheetTitlePlural", { count: viewers.length })}
{viewers.length === 0 ? (
- No one has viewed this story yet.
+ {t("noViewersYet")}
) : (
diff --git a/src/components/video/VideoUploadForm.tsx b/src/components/video/VideoUploadForm.tsx
index 5844aa2..670143a 100644
--- a/src/components/video/VideoUploadForm.tsx
+++ b/src/components/video/VideoUploadForm.tsx
@@ -2,6 +2,7 @@
import { useState, useRef, useCallback } from "react";
import { Video, Loader2, CheckCircle2, AlertCircle } from "lucide-react";
+import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { useMutation } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
@@ -21,6 +22,7 @@ interface VideoUploadFormProps {
}
export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
+ const t = useTranslations("VideoUploadForm");
const trpc = useTRPC();
const { activePetId } = useActivePet();
const fileInputRef = useRef(null);
@@ -37,17 +39,17 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
const handleFileSelect = useCallback((file: File) => {
if (!ACCEPTED_TYPES.includes(file.type)) {
- toast.error("Upload an MP4, MOV, or WebM video.");
+ toast.error(t("invalidType"));
return;
}
if (file.size > MAX_FILE_SIZE) {
- toast.error("Video must be under 500 MB.");
+ toast.error(t("fileTooLarge"));
return;
}
setFile(file);
setPreviewUrl(URL.createObjectURL(file));
setUploadState("idle");
- }, []);
+ }, [t]);
const handleFileChange = useCallback(
(e: React.ChangeEvent) => {
@@ -86,15 +88,15 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
});
setUploadState("processing");
- toast.success("Video uploaded — processing in the background");
+ toast.success(t("uploadSuccessToast"));
onClose();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("[VideoUpload]", msg);
setUploadState("error");
- toast.error(`Upload failed: ${msg}`);
+ toast.error(t("uploadFailedToast", { msg }));
}
- }, [file, activePetId, caption, createUploadMutation, onClose]);
+ }, [file, activePetId, caption, createUploadMutation, onClose, t]);
return (
@@ -108,12 +110,12 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") fileInputRef.current?.click(); }}
- aria-label="Select video"
+ aria-label={t("selectVideoAria")}
>
-
Select a video
+
{t("selectVideo")}
- MP4, MOV or WebM · up to 500 MB · max 3 minutes
+ {t("formatHint")}
) : (
@@ -144,17 +146,17 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
onClick={() => { setFile(null); setPreviewUrl(null); }}
className="text-xs text-muted-foreground hover:text-foreground"
>
- Choose a different video
+ {t("changeVideo")}
)}
{/* Caption */}
{file && (
-
Caption
+
{t("captionLabel")}
@@ -174,7 +176,7 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
{uploadState === "uploading" && (
- Uploading…
+ {t("uploading")}
{uploadProgress}%
@@ -189,14 +191,14 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
{uploadState === "processing" && (
- Uploaded — video is being processed
+ {t("processingDone")}
)}
{uploadState === "error" && (
- Upload failed — please try again
+ {t("uploadFailedGeneric")}
)}
@@ -208,7 +210,7 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
disabled={uploadState === "uploading"}
className="flex-1"
>
- Cancel
+ {t("cancel")}
{uploadState === "uploading" ? (
- <> Uploading…>
+ <> {t("uploading")}>
) : (
- "Post video"
+ t("postVideo")
)}