feat(i18n): translate remaining hardcoded strings (audit finding #14)

Adds 6 new message namespaces (DeleteAccount, NotificationsPage,
PhotoPostForm, StoryForm, MilestoneForm, VideoUploadForm, StoryViewer,
StoryTray, RepostCard) plus a Health.share sub-namespace, with full
EN/DE parity (verified programmatically, 0 keys missing on either side):

- DeleteAccountDialog.tsx: was 100% hardcoded German for a destructive,
  irreversible action — now fully translated.
- notifications/page.tsx: mixed hardcoded English (FOLLOW/REACTION/...)
  with hardcoded German (BIRTHDAY/ADOPTION_DAY) in the same list.
- HealthDashboard.tsx + WeightChart.tsx: date formatting was hardcoded
  to toLocaleDateString("de-DE", ...) regardless of locale, unlike the
  DateWheelPicker in the same forms which correctly used useLocale().
  ShareDialog was entirely untranslated German alongside fully-translated
  sibling tabs.
- PhotoPostForm/MilestoneForm/StoryForm/VideoUploadForm: the entire
  post-creation flow had zero i18n despite the parent PostTypeSheet
  being fully translated.
- StoryViewer/StoryTray/RepostCard: no i18n at all.

health-card/[token]/page.tsx passes a fixed "de-DE" locale to the now
locale-aware WeightChart, since that whole page is a separate,
out-of-audit-scope public page that's intentionally all-German.
This commit is contained in:
admin committed 2026-07-22 19:55:01 +02:00
1 parent 4fcde7eb2a
commit 4ba6bbc0c3
14 files changed
+437 -148

No files matched your search

+130 -1
View File
@@ -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"
}
}
+130 -1
View File
@@ -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"
}
}
+13 -12
View File
@@ -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() {
<div className="max-w-[640px] mx-auto px-4 py-8">
<h1 className="text-xl font-semibold mb-6 flex items-center gap-2">
<Bell className="h-5 w-5 text-orange-500" aria-hidden="true" />
Notifications
{t("title")}
</h1>
{isLoading && (
@@ -101,9 +102,9 @@ export default function NotificationsPage() {
{!isLoading && notifications.length === 0 && (
<div className="flex flex-col items-center gap-3 py-20 text-muted-foreground">
<Bell className="h-10 w-10 opacity-30" />
<p className="text-sm">No notifications yet</p>
<p className="text-sm">{t("emptyTitle")}</p>
<p className="text-xs text-center max-w-[240px]">
When pets follow you or react to your posts, you&apos;ll see it here.
{t("emptyDesc")}
</p>
</div>
)}
@@ -164,22 +165,22 @@ export default function NotificationsPage() {
</Link>
)}{" "}
<span className="text-muted-foreground">
{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: &ldquo;
{t("commentedPrefix")} &ldquo;
{notif.comment.body.slice(0, 60)}
{notif.comment.body.length > 60 ? "…" : ""}
&rdquo;
</>
)}
{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")}
</span>
</p>
<p className="text-xs text-muted-foreground mt-0.5">
+4 -1
View File
@@ -183,7 +183,10 @@ export default async function HealthCardPage({ params, searchParams }: PageProps
</tbody>
</table>
{pet.weightLogs.length >= 2 && (
<WeightChart data={pet.weightLogs} />
// 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.
<WeightChart data={pet.weightLogs} locale="de-DE" />
)}
</div>
)}
+27 -23
View File
@@ -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 (
<>
<button
@@ -76,7 +87,7 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
onClick={handleOpen}
className="text-xs text-destructive/50 hover:text-destructive transition-colors"
>
Konto löschen
{t("trigger")}
</button>
<Dialog open={open} onOpenChange={handleClose}>
@@ -86,23 +97,16 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<TriangleAlert className="h-5 w-5 shrink-0" />
Konto wirklich löschen?
{t("warningTitle")}
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground mt-1">
Diese Aktion ist <strong>unwiderruflich</strong>. Folgendes wird dauerhaft gelöscht:
{t("warningIntro")}
</p>
<ul className="mt-3 space-y-1.5 text-sm text-muted-foreground">
{[
"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) => (
<li key={item} className="flex items-center gap-2">
<span className="text-destructive font-bold">✕</span>
{item}
@@ -112,10 +116,10 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
<div className="flex gap-2 justify-end mt-4">
<Button variant="outline" onClick={handleClose}>
Abbrechen
{t("cancel")}
</Button>
<Button variant="destructive" onClick={() => setStep("confirm")}>
Ich verstehe, weiter
{t("understand")}
</Button>
</div>
</>
@@ -126,12 +130,12 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<Trash2 className="h-5 w-5 shrink-0" />
Bestätigung erforderlich
{t("confirmTitle")}
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground mt-1">
Gib den Namen eines deiner Haustiere ein, um die Löschung zu bestätigen:
{t("confirmIntro")}
</p>
<div className="flex flex-wrap gap-1.5 mt-3">
@@ -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) {
<div className="flex gap-2 justify-end mt-4">
<Button variant="outline" onClick={handleClose}>
Abbrechen
{t("cancel")}
</Button>
<Button
variant="destructive"
disabled={!isValidName}
onClick={handleDelete}
>
Konto endgültig löschen
{t("confirmDelete")}
</Button>
</div>
</>
@@ -177,15 +181,15 @@ export function DeleteAccountDialog({ petNames, defaultOpen }: Props) {
{step === "loading" && (
<div className="flex flex-col items-center gap-4 py-10">
<Loader2 className="h-8 w-8 animate-spin text-orange-500" />
<p className="text-sm text-muted-foreground">Konto wird gelöscht…</p>
<p className="text-sm text-muted-foreground">{t("deleting")}</p>
</div>
)}
{step === "done" && (
<div className="flex flex-col items-center gap-4 py-10">
<CheckCircle2 className="h-8 w-8 text-emerald-500" />
<p className="text-sm font-medium">Konto erfolgreich gelöscht.</p>
<p className="text-xs text-muted-foreground">Du wirst weitergeleitet…</p>
<p className="text-sm font-medium">{t("doneTitle")}</p>
<p className="text-xs text-muted-foreground">{t("doneRedirect")}</p>
</div>
)}
</DialogContent>
+4 -2
View File
@@ -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<PostCardPost["repost"]>["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({
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
<PawPrint className="h-3.5 w-3.5 text-orange-400" aria-hidden="true" />
<span className="text-xs text-muted-foreground">
Pawed-back by{" "}
{t("pawedBackBy")}{" "}
<a href={`/pets/${post.pet.id}`} className="font-medium hover:underline">
@{reposterName}
</a>
@@ -81,7 +83,7 @@ function RepostCardInner({
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
<PawPrint className="h-3.5 w-3.5 text-orange-400" aria-hidden="true" />
<span className="text-xs text-muted-foreground">
Pawed-back by{" "}
{t("pawedBackBy")}{" "}
<Link href={`/pets/${post.pet.id}`} className="font-medium hover:underline">
@{reposterName}
</Link>
+29 -28
View File
@@ -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 }) {
<p className="text-sm text-muted-foreground text-center py-8">{t("weight.noEntries")}</p>
)}
{!isLoading && data.length >= 2 && <WeightChart data={data} />}
{!isLoading && data.length >= 2 && <WeightChart data={data} locale={locale} />}
{data.length > 0 && (
<div className="space-y-1">
@@ -158,7 +159,7 @@ function WeightTab({ petId }: { petId: string }) {
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{formatWeight(entry.weightGrams)}</p>
<p className="text-xs text-muted-foreground">
{formatDate(entry.date)}{entry.notes ? ` · ${entry.notes}` : ""}
{formatDate(entry.date, locale)}{entry.notes ? ` · ${entry.notes}` : ""}
</p>
</div>
<Button
@@ -182,6 +183,7 @@ function WeightTab({ petId }: { petId: string }) {
function VetVisitsTab({ petId }: { petId: string }) {
const t = useTranslations("Health");
const locale = useLocale();
const trpc = useTRPC();
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
@@ -281,7 +283,7 @@ function VetVisitsTab({ petId }: { petId: string }) {
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{visit.reason}</p>
<p className="text-xs text-muted-foreground">
{formatDate(visit.date)}{visit.vetName ? ` · ${visit.vetName}` : ""}
{formatDate(visit.date, locale)}{visit.vetName ? ` · ${visit.vetName}` : ""}
</p>
{visit.notes && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{visit.notes}</p>
@@ -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 && (
<Badge variant="secondary" className="text-xs">
{t("vaccines.badgeDue", { date: formatDate(vac.nextDueDate) })}
{t("vaccines.badgeDue", { date: formatDate(vac.nextDueDate, locale) })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("vaccines.textGiven", { date: formatDate(vac.dateGiven) })}{vac.notes ? ` · ${vac.notes}` : ""}
{t("vaccines.textGiven", { date: formatDate(vac.dateGiven, locale) })}{vac.notes ? ` · ${vac.notes}` : ""}
</p>
</div>
<Button
@@ -444,13 +447,7 @@ function VaccinesTab({ petId }: { petId: string }) {
// ── Share Dialog ──────────────────────────────────────────────────────────────
const EXPIRY_OPTIONS = [
{ value: "7", label: "7 Tage" },
{ value: "14", label: "14 Tage" },
{ value: "30", label: "30 Tage" },
{ value: "60", label: "60 Tage" },
{ value: "90", label: "90 Tage" },
] as const;
const EXPIRY_VALUES = ["7", "14", "30", "60", "90"] as const;
function ShareDialog({ petId, petName, open, onOpenChange }: {
petId: string;
@@ -458,6 +455,8 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const t = useTranslations("Health.share");
const locale = useLocale();
const trpc = useTRPC();
const [token, setToken] = useState<string | null>(null);
const [expiresAt, setExpiresAt] = useState<Date | null>(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 }: {
<Dialog open={open} onOpenChange={handleOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Health Card teilen — {petName}</DialogTitle>
<DialogTitle>{t("title", { petName })}</DialogTitle>
<DialogDescription>
Jeder mit diesem Link kann {petName}&apos;s Gesundheitsdaten einsehen — Gewicht, Impfungen und Tierarztbesuche. Kein Login erforderlich.
{t("description", { petName })}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Gültigkeitsdauer</Label>
<Label className="text-xs text-muted-foreground">{t("validityLabel")}</Label>
<div className="flex gap-2">
<Select value={expiryDays} onValueChange={(v) => setExpiryDays(v as typeof expiryDays)}>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
{EXPIRY_VALUES.map((value) => (
<SelectItem key={value} value={value}>
{t("expiryDays", { count: value })}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={handleGenerate} disabled={isPending} className="shrink-0">
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Generieren"}
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : t("generate")}
</Button>
</div>
</div>
@@ -556,22 +557,22 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
{expiresAt && (
<p className="text-xs text-muted-foreground">
Gültig bis <span className="font-medium text-foreground">{formatDate(expiresAt)}</span>
{t("validUntil")} <span className="font-medium text-foreground">{formatDate(expiresAt, locale)}</span>
</p>
)}
<Button variant="secondary" size="sm" className="w-full gap-2" onClick={handleOpenPdf}>
<ExternalLink className="h-4 w-4" />
Als PDF öffnen / drucken
{t("openPdf")}
</Button>
<Separator />
<div className="flex items-start gap-3">
<div className="flex-1">
<p className="text-sm font-medium">Link neu generieren</p>
<p className="text-sm font-medium">{t("regenerateTitle")}</p>
<p className="text-xs text-muted-foreground mt-0.5">
Erstellt einen neuen Link und macht den aktuellen ungültig. Nützlich zum Widerrufen von Zugriffen.
{t("regenerateDesc")}
</p>
</div>
<Button
+7 -5
View File
@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
export type ChartEntry = { date: Date | string; weightGrams: number };
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",
@@ -17,7 +18,8 @@ function formatWeight(grams: number): string {
return `${parseFloat((grams / 1000).toFixed(3))} kg`;
}
export function WeightChart({ data }: { data: ChartEntry[] }) {
export function WeightChart({ data, locale }: { data: ChartEntry[]; locale: string }) {
const t = useTranslations("Health.weight");
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
const sorted = [...data].sort(
@@ -86,7 +88,7 @@ export function WeightChart({ data }: { data: ChartEntry[] }) {
<div className="rounded-xl border bg-muted/20 p-4">
<div className="flex items-center justify-between mb-2 px-1">
<span className="text-xs font-medium text-muted-foreground tracking-wide uppercase">
Verlauf
{t("chartHistory")}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{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));
+21 -19
View File
@@ -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 */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
What&apos;s the milestone?
{t("questionLabel")}
</label>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger className="flex items-center justify-between w-full min-h-[44px] px-3 py-2 rounded-md border border-input bg-background text-sm hover:bg-accent hover:text-accent-foreground transition-colors">
@@ -160,7 +162,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
{selectedMeta.label}
</span>
) : (
<span className="text-muted-foreground">Select a milestone…</span>
<span className="text-muted-foreground">{t("selectPlaceholder")}</span>
)}
<ChevronDown className="h-4 w-4 text-muted-foreground shrink-0" aria-hidden="true" />
</PopoverTrigger>
@@ -191,10 +193,10 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
{selectedType === MilestoneType.LITTER && (
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Litter details <span className="text-muted-foreground font-normal">(optional)</span>
{t("litterDetailsLabel")} <span className="text-muted-foreground font-normal">{t("optional")}</span>
</label>
<Textarea
placeholder="e.g. 5 healthy kittens, born 13 June…"
placeholder={t("litterPlaceholder")}
value={notes}
onChange={(e) => setNotes(e.target.value)}
maxLength={500}
@@ -203,7 +205,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
/>
{notes.length > 450 && (
<p className="text-xs text-muted-foreground text-right">
{500 - notes.length} characters remaining
{t("charactersRemaining", { count: 500 - notes.length })}
</p>
)}
</div>
@@ -212,7 +214,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
{/* Optional photo */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Photo <span className="text-muted-foreground font-normal">(optional)</span>
{t("photoLabel")} <span className="text-muted-foreground font-normal">{t("optional")}</span>
</label>
<input
type="file"
@@ -227,7 +229,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
onClick={() => fileInputRef.current?.click()}
>
<ImagePlus className="h-7 w-7 text-muted-foreground" aria-hidden="true" />
<span className="text-sm text-muted-foreground">Add a photo</span>
<span className="text-sm text-muted-foreground">{t("addPhoto")}</span>
</button>
) : (
<div className="relative rounded-xl overflow-hidden">
@@ -249,7 +251,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
onClick={() => fileInputRef.current?.click()}
>
<Camera className="h-3 w-3 inline mr-1" aria-hidden="true" />
Change
{t("change")}
</button>
)}
</div>
@@ -260,10 +262,10 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
{/* Optional caption */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-foreground">
Add a note <span className="text-muted-foreground font-normal">(optional)</span>
{t("noteLabel")} <span className="text-muted-foreground font-normal">{t("optional")}</span>
</label>
<Textarea
placeholder="Add a note… (optional)"
placeholder={t("notePlaceholder")}
value={caption}
onChange={(e) => setCaption(e.target.value)}
maxLength={500}
@@ -272,7 +274,7 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
/>
{caption.length > 450 && (
<p className="text-xs text-muted-foreground text-right">
{500 - caption.length} characters remaining
{t("charactersRemaining", { count: 500 - caption.length })}
</p>
)}
</div>
@@ -285,10 +287,10 @@ export function MilestoneForm({ onClose }: MilestoneFormProps) {
{createMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
Sharing…
{t("sharing")}
</>
) : (
"Share Milestone"
t("shareCta")
)}
</Button>
</div>
+13 -11
View File
@@ -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 (
<div className="p-6 text-center text-sm text-muted-foreground">
No active pet selected.
{t("noPetSelected")}
</div>
);
}
@@ -104,12 +106,12 @@ export function PhotoPostForm({ onClose }: PhotoPostFormProps) {
{/* Caption textarea */}
<div className="space-y-1">
<MentionTextarea
placeholder="Write a caption…"
placeholder={t("captionPlaceholder")}
value={caption}
onChange={(val) => {
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 && (
<p className="text-xs text-muted-foreground text-right">
{500 - caption.length} characters remaining
{t("charactersRemaining", { count: 500 - caption.length })}
</p>
)}
</div>
@@ -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")}
</Button>
</div>
);
+16 -14
View File
@@ -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")}
>
<input
type="file"
@@ -161,9 +163,9 @@ export function StoryForm({ onClose }: StoryFormProps) {
<>
<ImagePlus className="h-10 w-10 text-muted-foreground" aria-hidden="true" />
<div className="text-center">
<p className="text-sm font-medium">Add a photo</p>
<p className="text-sm font-medium">{t("addPhoto")}</p>
<p className="text-xs text-muted-foreground mt-0.5">
Stories disappear after 24 hours
{t("disappearsNote")}
</p>
</div>
</>
@@ -195,7 +197,7 @@ export function StoryForm({ onClose }: StoryFormProps) {
}}
>
<Camera className="h-3 w-3 inline mr-1" aria-hidden="true" />
Change photo
{t("changePhoto")}
</button>
)}
</>
@@ -207,7 +209,7 @@ export function StoryForm({ onClose }: StoryFormProps) {
)}
<p className="text-xs text-muted-foreground text-center">
Stories disappear after 24 hours
{t("disappearsNote")}
</p>
<Button
@@ -218,10 +220,10 @@ export function StoryForm({ onClose }: StoryFormProps) {
{uploadState === "submitting" ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" aria-hidden="true" />
Posting…
{t("posting")}
</>
) : (
"Post Story"
t("postCta")
)}
</Button>
</div>
+6 -4
View File
@@ -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() {
<div
className="flex gap-4 overflow-x-auto px-6 py-3 scrollbar-hide"
role="list"
aria-label="Stories"
aria-label={t("storiesAriaLabel")}
>
{/* Add story item — wrapper matches PawRing container height so circles align */}
<div className="flex flex-col items-center gap-1 shrink-0" role="listitem">
@@ -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")}
>
<PlusCircle className="h-7 w-7 text-white" aria-hidden="true" />
</button>
</div>
<span className="text-xs text-muted-foreground truncate max-w-[56px]">
Add story
{t("addStory")}
</span>
</div>
@@ -124,7 +126,7 @@ export function StoryTray() {
<Dialog open={addStoryOpen} onOpenChange={setAddStoryOpen}>
<DialogContent className="sm:max-w-[512px] max-h-[90dvh] overflow-y-auto p-0">
<DialogHeader className="px-4 pt-4 pb-2">
<DialogTitle className="text-xl font-semibold">New Story</DialogTitle>
<DialogTitle className="text-xl font-semibold">{t("newStoryTitle")}</DialogTitle>
</DialogHeader>
<StoryForm onClose={() => setAddStoryOpen(false)} />
</DialogContent>
+16 -8
View File
@@ -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 */}
<div className="absolute top-0 left-0 right-0 flex gap-1 px-3 pt-3">
@@ -156,7 +158,7 @@ export function StoryViewer({
// eslint-disable-next-line @next/next/no-img-element
<img
src={mediaUrl}
alt={`${petName}'s story`}
alt={t("storyAriaLabel", { petName })}
className="max-h-screen max-w-full object-contain"
/>
)}
@@ -180,7 +182,7 @@ export function StoryViewer({
<button
className="absolute top-8 right-4 flex items-center justify-center w-11 h-11 rounded-full bg-black/40 text-white hover:bg-black/60"
onClick={handleClose}
aria-label="Close story"
aria-label={t("closeStory")}
>
<X className="h-5 w-5" aria-hidden="true" />
</button>
@@ -190,7 +192,11 @@ export function StoryViewer({
<button
className="absolute top-8 left-4 flex items-center gap-1.5 px-3 py-2 rounded-full bg-black/40 text-white text-xs hover:bg-black/60"
onClick={() => setViewerSheetOpen(true)}
aria-label={`Seen by ${viewers.length} pets`}
aria-label={
viewers.length === 1
? t("sheetTitleSingular")
: t("sheetTitlePlural", { count: viewers.length })
}
>
<Eye className="h-3.5 w-3.5" aria-hidden="true" />
<span>{viewers.length}</span>
@@ -204,14 +210,16 @@ export function StoryViewer({
<SheetContent side="bottom" className="pb-8 max-h-[60dvh] overflow-y-auto">
<SheetHeader className="mb-4">
<SheetTitle className="text-lg font-semibold">
{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 })}
</SheetTitle>
</SheetHeader>
{viewers.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No one has viewed this story yet.
{t("noViewersYet")}
</p>
) : (
<ul className="space-y-3">
+21 -19
View File
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
@@ -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 (
<div className="p-4 space-y-4">
@@ -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")}
>
<Video className="h-10 w-10 text-muted-foreground mx-auto mb-3" aria-hidden="true" />
<p className="text-sm font-medium">Select a video</p>
<p className="text-sm font-medium">{t("selectVideo")}</p>
<p className="text-xs text-muted-foreground mt-1">
MP4, MOV or WebM · up to 500 MB · max 3 minutes
{t("formatHint")}
</p>
</div>
) : (
@@ -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")}
</button>
)}
{/* Caption */}
{file && (
<div className="space-y-1.5">
<Label htmlFor="video-caption">Caption</Label>
<Label htmlFor="video-caption">{t("captionLabel")}</Label>
<Textarea
id="video-caption"
placeholder="Write a caption…"
placeholder={t("captionPlaceholder")}
value={caption}
onChange={(e) => setCaption(e.target.value)}
maxLength={500}
@@ -164,7 +166,7 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
/>
{caption.length > 450 && (
<p className="text-xs text-muted-foreground text-right">
{500 - caption.length} left
{t("charactersLeft", { count: 500 - caption.length })}
</p>
)}
</div>
@@ -174,7 +176,7 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
{uploadState === "uploading" && (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Uploading…</span>
<span>{t("uploading")}</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
@@ -189,14 +191,14 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
{uploadState === "processing" && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 text-green-500" />
Uploaded — video is being processed
{t("processingDone")}
</div>
)}
{uploadState === "error" && (
<div className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
Upload failed — please try again
{t("uploadFailedGeneric")}
</div>
)}
@@ -208,7 +210,7 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
disabled={uploadState === "uploading"}
className="flex-1"
>
Cancel
{t("cancel")}
</Button>
<Button
onClick={handleSubmit}
@@ -216,9 +218,9 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
className="flex-1 bg-orange-500 hover:bg-orange-600 text-white"
>
{uploadState === "uploading" ? (
<><Loader2 className="h-4 w-4 animate-spin mr-2" /> Uploading…</>
<><Loader2 className="h-4 w-4 animate-spin mr-2" /> {t("uploading")}</>
) : (
"Post video"
t("postVideo")
)}
</Button>
</div>