diff --git a/messages/de.json b/messages/de.json
index 907b80e..8236d57 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -134,7 +134,9 @@
"placeholder": "Kommentar hinzufügen…",
"post": "Senden",
"postError": "Kommentar konnte nicht gepostet werden. Bitte versuche es erneut.",
- "deleteError": "Kommentar konnte nicht gelöscht werden. Bitte versuche es erneut."
+ "deleteError": "Kommentar konnte nicht gelöscht werden. Bitte versuche es erneut.",
+ "countSingular": "1 Kommentar",
+ "countPlural": "{count} Kommentare"
},
"Follow": {
"follow": "Folgen",
@@ -159,5 +161,101 @@
"forYou": "Für dich",
"recent": "Aktuell",
"allCaughtUp": "Du bist auf dem neuesten Stand. Folge mehr Tieren für weitere Beiträge."
+ },
+ "ProfileTabs": {
+ "posts": "Beiträge",
+ "milestones": "Meilensteine",
+ "noPosts": "Noch keine Beiträge",
+ "noMilestones": "Noch keine Meilensteine"
+ },
+ "ExploreCard": {
+ "followerSingular": "1 Follower",
+ "followerPlural": "{count} Follower",
+ "yourPet": "Dein Tier"
+ },
+ "Health": {
+ "pageTitle": "Gesundheit — {petName}",
+ "shareCard": "Karte teilen",
+ "tabWeight": "Gewicht",
+ "tabVet": "Tierarzt",
+ "tabVaccines": "Impfungen",
+ "cancel": "Abbrechen",
+ "save": "Speichern",
+ "weight": {
+ "add": "Gewicht hinzufügen",
+ "noEntries": "Noch keine Gewichtseinträge",
+ "singular": "1 Eintrag",
+ "plural": "{count} Einträge",
+ "labelDate": "Datum",
+ "labelWeight": "Gewicht (kg)",
+ "placeholderWeight": "z.B. 4,2",
+ "labelNotes": "Notizen (optional)",
+ "placeholderNotes": "z.B. nach dem Morgenspaziergang",
+ "toastAdded": "Gewichtseintrag hinzugefügt",
+ "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"
+ },
+ "vet": {
+ "add": "Besuch hinzufügen",
+ "noEntries": "Noch keine Tierarztbesuche",
+ "singular": "1 Besuch",
+ "plural": "{count} Besuche",
+ "labelDate": "Datum",
+ "labelVet": "Tierarzt / Klinik (optional)",
+ "placeholderVet": "z.B. Dr. Müller",
+ "labelReason": "Grund *",
+ "placeholderReason": "z.B. Jahresuntersuchung",
+ "labelNotes": "Notizen (optional)",
+ "placeholderNotes": "Diagnose, Behandlung, Medikamente…",
+ "toastAdded": "Tierarztbesuch hinzugefügt",
+ "toastDeleted": "Eintrag gelöscht",
+ "toastError": "Tierarztbesuch konnte nicht gespeichert werden",
+ "toastDeleteError": "Eintrag konnte nicht gelöscht werden",
+ "toastValidation": "Bitte Datum und Grund eingeben"
+ },
+ "vaccines": {
+ "add": "Impfung hinzufügen",
+ "noEntries": "Noch keine Impfungen eingetragen",
+ "singular": "1 Impfung",
+ "plural": "{count} Impfungen",
+ "labelName": "Impfstoff *",
+ "placeholderName": "z.B. Tollwut, DHPP",
+ "labelDateGiven": "Gegeben am *",
+ "labelNextDue": "Nächste Fälligkeit (optional)",
+ "labelNotes": "Notizen (optional)",
+ "placeholderNotes": "Chargennummer, Klinik…",
+ "badgeOverdue": "Überfällig",
+ "badgeDue": "Fällig {date}",
+ "textGiven": "Gegeben {date}",
+ "toastAdded": "Impfung hinzugefügt",
+ "toastDeleted": "Eintrag gelöscht",
+ "toastError": "Impfung konnte nicht gespeichert werden",
+ "toastDeleteError": "Eintrag konnte nicht gelöscht werden",
+ "toastValidation": "Bitte Impfstoffname und Datum eingeben"
+ },
+ "feeding": {
+ "sectionTitle": "Fütterung",
+ "edit": "Bearbeiten",
+ "add": "Fütterungsinfo hinzufügen",
+ "labelFoodType": "Futterart",
+ "labelFeedingTimes": "Mahlzeiten pro Tag",
+ "labelDailyAmount": "Tagesration (g)",
+ "labelBrand": "Marke (optional)",
+ "labelSpecialDiet": "Spezialdiät / Allergien (optional)",
+ "placeholderFoodType": "z.B. Trockenfutter, Nassfutter, BARF",
+ "placeholderFeedingTimes": "z.B. 2",
+ "placeholderDailyAmount": "z.B. 200",
+ "placeholderBrand": "z.B. Royal Canin",
+ "placeholderSpecialDiet": "z.B. Getreidefrei, Hühnchen-Allergie…",
+ "noInfo": "Noch keine Fütterungsinfos hinterlegt.",
+ "timesPerDay": "{count}x täglich",
+ "gramsPerDay": "{amount} g / Tag",
+ "toastSaved": "Fütterungsinfo gespeichert",
+ "toastDeleted": "Fütterungsinfo entfernt",
+ "toastError": "Fütterungsinfo konnte nicht gespeichert werden",
+ "toastDeleteError": "Fütterungsinfo konnte nicht entfernt werden"
+ }
}
}
diff --git a/messages/en.json b/messages/en.json
index 27e56d9..c00dab8 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -134,7 +134,9 @@
"placeholder": "Add a comment…",
"post": "Post",
"postError": "Couldn't post comment. Please try again.",
- "deleteError": "Couldn't delete comment. Please try again."
+ "deleteError": "Couldn't delete comment. Please try again.",
+ "countSingular": "1 comment",
+ "countPlural": "{count} comments"
},
"Follow": {
"follow": "Follow",
@@ -159,5 +161,101 @@
"forYou": "For you",
"recent": "Recent",
"allCaughtUp": "You're all caught up. Follow more pets to see more posts."
+ },
+ "ProfileTabs": {
+ "posts": "Posts",
+ "milestones": "Milestones",
+ "noPosts": "No posts yet",
+ "noMilestones": "No milestones yet"
+ },
+ "ExploreCard": {
+ "followerSingular": "1 follower",
+ "followerPlural": "{count} followers",
+ "yourPet": "Your pet"
+ },
+ "Health": {
+ "pageTitle": "Health — {petName}",
+ "shareCard": "Share Card",
+ "tabWeight": "Weight",
+ "tabVet": "Vet Visits",
+ "tabVaccines": "Vaccines",
+ "cancel": "Cancel",
+ "save": "Save",
+ "weight": {
+ "add": "Add Weight",
+ "noEntries": "No weight entries yet",
+ "singular": "1 entry",
+ "plural": "{count} entries",
+ "labelDate": "Date",
+ "labelWeight": "Weight (kg)",
+ "placeholderWeight": "e.g. 4.2",
+ "labelNotes": "Notes (optional)",
+ "placeholderNotes": "e.g. after morning walk",
+ "toastAdded": "Weight entry added",
+ "toastDeleted": "Entry deleted",
+ "toastError": "Failed to save weight entry",
+ "toastDeleteError": "Failed to delete entry",
+ "toastValidation": "Please fill in date and a valid weight"
+ },
+ "vet": {
+ "add": "Add Visit",
+ "noEntries": "No vet visits yet",
+ "singular": "1 visit",
+ "plural": "{count} visits",
+ "labelDate": "Date",
+ "labelVet": "Vet / Clinic (optional)",
+ "placeholderVet": "e.g. Dr. Müller",
+ "labelReason": "Reason *",
+ "placeholderReason": "e.g. Annual checkup",
+ "labelNotes": "Notes (optional)",
+ "placeholderNotes": "Diagnosis, treatment, medications…",
+ "toastAdded": "Vet visit added",
+ "toastDeleted": "Entry deleted",
+ "toastError": "Failed to save vet visit",
+ "toastDeleteError": "Failed to delete entry",
+ "toastValidation": "Please fill in date and reason"
+ },
+ "vaccines": {
+ "add": "Add Vaccine",
+ "noEntries": "No vaccines recorded yet",
+ "singular": "1 vaccine",
+ "plural": "{count} vaccines",
+ "labelName": "Vaccine Name *",
+ "placeholderName": "e.g. Rabies, DHPP",
+ "labelDateGiven": "Date Given *",
+ "labelNextDue": "Next Due (optional)",
+ "labelNotes": "Notes (optional)",
+ "placeholderNotes": "Batch number, clinic…",
+ "badgeOverdue": "Overdue",
+ "badgeDue": "Due {date}",
+ "textGiven": "Given {date}",
+ "toastAdded": "Vaccine added",
+ "toastDeleted": "Entry deleted",
+ "toastError": "Failed to save vaccine",
+ "toastDeleteError": "Failed to delete entry",
+ "toastValidation": "Please fill in vaccine name and date given"
+ },
+ "feeding": {
+ "sectionTitle": "Feeding",
+ "edit": "Edit",
+ "add": "Add Feeding Info",
+ "labelFoodType": "Food type",
+ "labelFeedingTimes": "Feedings per day",
+ "labelDailyAmount": "Daily amount (g)",
+ "labelBrand": "Brand (optional)",
+ "labelSpecialDiet": "Special diet / allergies (optional)",
+ "placeholderFoodType": "e.g. Dry food, Wet food, BARF",
+ "placeholderFeedingTimes": "e.g. 2",
+ "placeholderDailyAmount": "e.g. 200",
+ "placeholderBrand": "e.g. Royal Canin",
+ "placeholderSpecialDiet": "e.g. Grain-free, chicken allergy…",
+ "noInfo": "No feeding info saved yet.",
+ "timesPerDay": "{count}x per day",
+ "gramsPerDay": "{amount} g / day",
+ "toastSaved": "Feeding info saved",
+ "toastDeleted": "Feeding info removed",
+ "toastError": "Could not save feeding info",
+ "toastDeleteError": "Could not remove feeding info"
+ }
}
}
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index b74ba39..d742206 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -117,6 +117,7 @@ model Pet {
vaccines Vaccine[]
healthCard HealthCard?
emergencyVet EmergencyVet?
+ feedingInfo PetFeedingInfo?
// Ad interaction back-relations
adReactions AdReaction[] @relation("AdReactions")
@@ -459,6 +460,18 @@ model HealthCard {
updatedAt DateTime @updatedAt
}
+model PetFeedingInfo {
+ id String @id @default(cuid())
+ pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
+ petId String @unique
+ foodType String? @db.VarChar(100)
+ feedingTimes Int?
+ dailyAmountG Int?
+ foodBrand String? @db.VarChar(100)
+ specialDiet String? @db.VarChar(500)
+ updatedAt DateTime @updatedAt
+}
+
// ─── Phase 4 Enums ───────────────────────────────────────────────────────────
enum NotificationType {
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx
index 7f388d9..a6465fd 100644
--- a/src/app/(app)/layout.tsx
+++ b/src/app/(app)/layout.tsx
@@ -4,6 +4,7 @@ import { cookies } from "next/headers";
import { createTRPCCaller } from "@/trpc/server";
import { Sidebar } from "@/components/layout/Sidebar";
import { MobileNav } from "@/components/layout/MobileNav";
+import { ActivePetInitializer } from "@/components/layout/ActivePetInitializer";
/**
* Protected app shell layout — applied to all routes under /(app)/.
@@ -54,6 +55,7 @@ export default async function AppLayout({
return (
+
{/* Desktop sidebar — hidden on mobile */}
diff --git a/src/app/health-card/[token]/page.tsx b/src/app/health-card/[token]/page.tsx
index 2b77a13..6bd70aa 100644
--- a/src/app/health-card/[token]/page.tsx
+++ b/src/app/health-card/[token]/page.tsx
@@ -46,6 +46,7 @@ export default async function HealthCardPage({ params, searchParams }: PageProps
weightLogs: { orderBy: { date: "desc" }, take: 20 },
vetVisits: { orderBy: { date: "desc" }, take: 10 },
vaccines: { orderBy: { dateGiven: "desc" } },
+ feedingInfo: true,
},
},
},
@@ -218,14 +219,56 @@ export default async function HealthCardPage({ params, searchParams }: PageProps
)}
+ {/* Feeding info */}
+ {pet.feedingInfo && (
+
+
+
+ Fütterung
+
+
+ {pet.feedingInfo.foodType && (
+
+
Futterart
+
{pet.feedingInfo.foodType}
+
+ )}
+ {(pet.feedingInfo.feedingTimes != null || pet.feedingInfo.dailyAmountG != null) && (
+
+
Ration
+
+ {pet.feedingInfo.feedingTimes != null && `${pet.feedingInfo.feedingTimes}x täglich`}
+ {pet.feedingInfo.feedingTimes != null && pet.feedingInfo.dailyAmountG != null && " · "}
+ {pet.feedingInfo.dailyAmountG != null && `${pet.feedingInfo.dailyAmountG} g / Tag`}
+
+
+ )}
+ {pet.feedingInfo.foodBrand && (
+
+
Marke
+
{pet.feedingInfo.foodBrand}
+
+ )}
+ {pet.feedingInfo.specialDiet && (
+
+
Spezialdiät / Allergien
+
{pet.feedingInfo.specialDiet}
+
+ )}
+
+
+ )}
+
{pet.vaccines.length === 0 &&
pet.weightLogs.length === 0 &&
- pet.vetVisits.length === 0 && (
+ pet.vetVisits.length === 0 &&
+ !pet.feedingInfo && (
Noch keine Gesundheitsdaten vorhanden.
)}
+
{/* Footer */}
Erstellt via PawFeed · {new Date().toLocaleDateString("de-DE")}
diff --git a/src/app/onboarding/pet/page.tsx b/src/app/onboarding/pet/page.tsx
index f621090..cc1e597 100644
--- a/src/app/onboarding/pet/page.tsx
+++ b/src/app/onboarding/pet/page.tsx
@@ -6,6 +6,7 @@ import { useTranslations } from "next-intl";
import { PetForm } from "@/components/pet/PetForm";
import { AvatarUpload } from "@/components/pet/AvatarUpload";
import { Button } from "@/components/ui/button";
+import { useActivePet } from "@/context/ActivePetContext";
/**
* Onboarding pet page — /onboarding/pet
@@ -19,6 +20,7 @@ import { Button } from "@/components/ui/button";
export default function OnboardingPetPage() {
const router = useRouter();
const t = useTranslations("Onboarding");
+ const { setActivePet } = useActivePet();
const [createdPetId, setCreatedPetId] = useState
(null);
if (createdPetId) {
@@ -59,7 +61,10 @@ export default function OnboardingPetPage() {
{t("addFirstPetDesc")}
setCreatedPetId(petId)}
+ onSuccess={(petId) => {
+ setActivePet(petId);
+ setCreatedPetId(petId);
+ }}
/>
diff --git a/src/components/explore/ExploreCard.tsx b/src/components/explore/ExploreCard.tsx
index df5ed2e..e35ec6e 100644
--- a/src/components/explore/ExploreCard.tsx
+++ b/src/components/explore/ExploreCard.tsx
@@ -1,6 +1,7 @@
"use client";
import Link from "next/link";
+import { useTranslations } from "next-intl";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { FollowButton } from "@/components/social/FollowButton";
@@ -21,6 +22,7 @@ interface ExploreCardProps {
}
export function ExploreCard({ pet, activePetId }: ExploreCardProps) {
+ const t = useTranslations("ExploreCard");
const avatarUrl = getAvatarUrl(pet.avatarKey);
const isOwnPet = pet.id === activePetId;
const labelName = pet.breed?.name ?? pet.species.name;
@@ -46,11 +48,11 @@ export function ExploreCard({ pet, activePetId }: ExploreCardProps) {
- {followerCount === 1 ? "1 follower" : `${followerCount} followers`}
+ {followerCount === 1 ? t("followerSingular") : t("followerPlural", { count: followerCount })}
{isOwnPet ? (
- Your pet
+ {t("yourPet")}
) : (
void;
-}
-
-export function CommentSheet({ postId, petId, open, onOpenChange }: CommentSheetProps) {
- const t = useTranslations("Comments");
- const trpc = useTRPC();
-
- function formatRelativeTime(date: Date | string): string {
- const diffMs = Date.now() - new Date(date).getTime();
- const diffMins = Math.floor(diffMs / 60000);
- const diffHours = Math.floor(diffMins / 60);
- const diffDays = Math.floor(diffHours / 24);
- if (diffMins < 1) return t("justNow");
- if (diffMins < 60) return `${diffMins}m`;
- if (diffHours < 24) return `${diffHours}h`;
- if (diffDays < 7) return `${diffDays}d`;
- return new Date(date).toLocaleDateString();
- }
- const queryClient = useQueryClient();
- const [body, setBody] = useState("");
- const textareaRef = useRef(null);
-
- const { data, isLoading } = useQuery(
- trpc.comments.listByPost.queryOptions({ postId, limit: 50 })
- );
- const comments = data?.comments ?? [];
-
- const createMutation = useMutation(
- trpc.comments.create.mutationOptions({
- onSuccess: () => {
- setBody("");
- void queryClient.invalidateQueries({
- queryKey: [["trpc", "comments", "listByPost"]],
- });
- void queryClient.invalidateQueries({
- queryKey: [["trpc", "feed", "getFeed"]],
- });
- },
- onError: () => toast.error(t("postError")),
- })
- );
-
- const deleteMutation = useMutation(
- trpc.comments.delete.mutationOptions({
- onSuccess: () => {
- void queryClient.invalidateQueries({
- queryKey: [["trpc", "comments", "listByPost"]],
- });
- void queryClient.invalidateQueries({
- queryKey: [["trpc", "feed", "getFeed"]],
- });
- },
- onError: () => toast.error(t("deleteError")),
- })
- );
-
- const handleSubmit = useCallback(() => {
- const trimmed = body.trim();
- if (!trimmed || createMutation.isPending) return;
- createMutation.mutate({ postId, petId, body: trimmed });
- }, [body, postId, petId, createMutation]);
-
- const handleKeyDown = useCallback(
- (e: React.KeyboardEvent) => {
- if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
- e.preventDefault();
- handleSubmit();
- }
- },
- [handleSubmit]
- );
-
- // Auto-focus textarea when sheet opens
- useEffect(() => {
- if (open) {
- setTimeout(() => textareaRef.current?.focus(), 100);
- }
- }, [open]);
-
- return (
-
-
-
- {t("title")}
-
-
-
-
-
- {isLoading ? (
-
-
-
- ) : comments.length === 0 ? (
-
- {t("empty")}
-
- ) : (
-
- {comments.map((comment) => {
- const isOwn = comment.pet.id === petId;
- const avatarUrl = getAvatarUrl(comment.pet.avatarKey);
- return (
-
-
- {avatarUrl && (
-
- )}
-
- {comment.pet.name[0]?.toUpperCase() ?? "?"}
-
-
-
-
-
- {comment.pet.name}
-
-
- {formatRelativeTime(comment.createdAt)}
-
-
-
- {parseCaption(comment.body).map((seg, i) =>
- seg.type === "hashtag" ? (
- #{seg.tag}
- ) : seg.type === "mention" ? (
- @{seg.name}
- ) : (
- {seg.text}
- )
- )}
-
-
- {isOwn && (
-
- )}
-
- );
- })}
-
- )}
-
-
-
-
- {/* Comment input — sticky at bottom */}
-
- setBody(val)}
- onKeyDown={handleKeyDown}
- className="flex-1 resize-none min-h-[40px] max-h-[120px] text-sm"
- />
-
-
-
-
- );
-}
diff --git a/src/components/feed/PostDetailDialog.tsx b/src/components/feed/PostDetailDialog.tsx
new file mode 100644
index 0000000..9fe7782
--- /dev/null
+++ b/src/components/feed/PostDetailDialog.tsx
@@ -0,0 +1,484 @@
+"use client";
+
+import { useState, useRef, useCallback, useEffect } from "react";
+import { MoreHorizontal, Trash2, Loader2 } from "lucide-react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { useTRPC } from "@/trpc/client";
+import { useActivePet } from "@/context/ActivePetContext";
+import { Dialog, DialogContent } from "@/components/ui/dialog";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import { MentionTextarea } from "@/components/ui/mention-textarea";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselNext,
+ CarouselPrevious,
+ type CarouselApi,
+} from "@/components/ui/carousel";
+import Link from "next/link";
+import { getAvatarUrl } from "@/lib/avatar-url";
+import { getMediaUrl } from "@/lib/media-url";
+import { parseCaption } from "@/lib/parse-caption";
+import { PawButton } from "@/components/feed/PawButton";
+import { PawBackButton } from "@/components/feed/PawBackButton";
+import { PawRing } from "@/components/stories/PawRing";
+import type { PostCardPost } from "@/components/feed/PostCard";
+import { useTranslations } from "next-intl";
+
+interface PostDetailDialogProps {
+ post: PostCardPost | null;
+ open: boolean;
+ onClose: () => void;
+ onDeleted?: (postId: string) => void;
+}
+
+export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailDialogProps) {
+ const t = useTranslations("PostCard");
+ const tc = useTranslations("Comments");
+ const trpc = useTRPC();
+ const { activePetId } = useActivePet();
+ const queryClient = useQueryClient();
+
+ const [carouselApi, setCarouselApi] = useState();
+ const [currentSlide, setCurrentSlide] = useState(1);
+ const [isEditingCaption, setIsEditingCaption] = useState(false);
+ const [editCaptionValue, setEditCaptionValue] = useState("");
+ const [currentCaption, setCurrentCaption] = useState(null);
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+ const [body, setBody] = useState("");
+ const commentsEndRef = useRef(null);
+
+ useEffect(() => {
+ if (!post) return;
+ setCurrentCaption(post.caption);
+ setEditCaptionValue(post.caption ?? "");
+ setIsEditingCaption(false);
+ setBody("");
+ setCurrentSlide(1);
+ }, [post?.id]);
+
+ useEffect(() => {
+ if (!carouselApi) return;
+ setCurrentSlide(carouselApi.selectedScrollSnap() + 1);
+ carouselApi.on("select", () => setCurrentSlide(carouselApi.selectedScrollSnap() + 1));
+ }, [carouselApi]);
+
+ const { data: commentsData, isLoading: commentsLoading } = useQuery({
+ ...trpc.comments.listByPost.queryOptions({ postId: post?.id ?? "", limit: 50 }),
+ enabled: !!post?.id && open,
+ });
+
+ const updateCaptionMutation = useMutation(
+ trpc.posts.updateCaption.mutationOptions({
+ onSuccess: (updated) => {
+ setCurrentCaption(updated.caption);
+ setIsEditingCaption(false);
+ toast.success(t("captionUpdated"));
+ },
+ onError: () => toast.error(t("captionUpdateError")),
+ })
+ );
+
+ const deleteMutation = useMutation(
+ trpc.posts.delete.mutationOptions({
+ onSuccess: () => {
+ setShowDeleteDialog(false);
+ toast.success(t("postDeleted"));
+ onClose();
+ if (post) onDeleted?.(post.id);
+ },
+ onError: () => toast.error(t("deleteError")),
+ })
+ );
+
+ const createCommentMutation = useMutation(
+ trpc.comments.create.mutationOptions({
+ onSuccess: () => {
+ setBody("");
+ void queryClient.invalidateQueries({ queryKey: [["trpc", "comments", "listByPost"]] });
+ void queryClient.invalidateQueries({ queryKey: [["trpc", "feed", "getFeed"]] });
+ setTimeout(() => commentsEndRef.current?.scrollIntoView({ behavior: "smooth" }), 150);
+ },
+ onError: () => toast.error(tc("postError")),
+ })
+ );
+
+ const deleteCommentMutation = useMutation(
+ trpc.comments.delete.mutationOptions({
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: [["trpc", "comments", "listByPost"]] });
+ },
+ onError: () => toast.error(tc("deleteError")),
+ })
+ );
+
+ const handleSubmitComment = useCallback(() => {
+ if (!body.trim() || !post || !activePetId) return;
+ createCommentMutation.mutate({ postId: post.id, petId: activePetId, body: body.trim() });
+ }, [body, post, activePetId, createCommentMutation]);
+
+ const handleCommentKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
+ e.preventDefault();
+ handleSubmitComment();
+ }
+ },
+ [handleSubmitComment]
+ );
+
+ function formatRelativeTime(date: Date | string): string {
+ const diffMs = Date.now() - new Date(date).getTime();
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMins / 60);
+ const diffDays = Math.floor(diffHours / 24);
+ if (diffMins < 1) return tc("justNow");
+ if (diffMins < 60) return `${diffMins}m`;
+ if (diffHours < 24) return `${diffHours}h`;
+ if (diffDays < 7) return `${diffDays}d`;
+ return new Date(date).toLocaleDateString();
+ }
+
+ if (!post) return null;
+
+ const { pet, images } = post;
+ const isCarousel = images.length > 1;
+ const isOwnPost = activePetId === pet.id;
+ const avatarUrl = getAvatarUrl(pet.avatarKey);
+ const comments = commentsData?.comments ?? [];
+
+ // Extracted so it renders identically in the mobile bar and the desktop right-panel
+ const headerInner = (
+ <>
+
+
+
+ {avatarUrl && }
+
+ {pet.name[0]?.toUpperCase() ?? "?"}
+
+
+
+
+
{pet.name}
+
{formatRelativeTime(post.createdAt)}
+
+
+
+
+
+
+
+
+ {isOwnPost ? (
+ <>
+ { setEditCaptionValue(currentCaption ?? ""); setIsEditingCaption(true); }}
+ className="cursor-pointer"
+ >
+ {t("editCaption")}
+
+ setShowDeleteDialog(true)}
+ className="cursor-pointer text-destructive focus:text-destructive"
+ >
+ {t("deletePost")}
+
+ >
+ ) : (
+ <>
+ {t("reportPost")}
+
+
+ {t("blockPet", { name: pet.name })}
+
+ >
+ )}
+
+
+ >
+ );
+
+ const imageContent = images.length > 0 && (
+ isCarousel ? (
+
+
+
+ {images.map((img) => (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ ))}
+
+
+
+
+
+ {currentSlide} / {images.length}
+
+
+ {images.map((_, i) => (
+
+
+ ) : (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ )
+ );
+
+ return (
+ <>
+
+
+
+
+
+ {t("deleteTitle")}
+ {t("deleteDesc")}
+
+
+ {t("keepPost")}
+ deleteMutation.mutate({ postId: post.id })}
+ disabled={deleteMutation.isPending}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {t("yesDelete")}
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/health/FeedingSection.tsx b/src/components/health/FeedingSection.tsx
new file mode 100644
index 0000000..a642da9
--- /dev/null
+++ b/src/components/health/FeedingSection.tsx
@@ -0,0 +1,218 @@
+"use client";
+
+import { useState } from "react";
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useTranslations } from "next-intl";
+import { toast } from "sonner";
+import { Pencil, Trash2, Loader2, UtensilsCrossed } from "lucide-react";
+import { useTRPC } from "@/trpc/client";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Label } from "@/components/ui/label";
+
+interface Props { petId: string }
+
+export function FeedingSection({ petId }: Props) {
+ const t = useTranslations("Health.feeding");
+ const tc = useTranslations("Health");
+ const trpc = useTRPC();
+ const qc = useQueryClient();
+
+ const { data: info, isLoading } = useQuery(
+ trpc.health.getFeedingInfo.queryOptions({ petId })
+ );
+
+ const saveMutation = useMutation({
+ ...trpc.health.setFeedingInfo.mutationOptions(),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: trpc.health.getFeedingInfo.queryKey({ petId }) });
+ toast.success(t("toastSaved"));
+ setEditing(false);
+ },
+ onError: () => toast.error(t("toastError")),
+ });
+
+ const deleteMutation = useMutation({
+ ...trpc.health.deleteFeedingInfo.mutationOptions(),
+ onSuccess: () => {
+ void qc.invalidateQueries({ queryKey: trpc.health.getFeedingInfo.queryKey({ petId }) });
+ toast.success(t("toastDeleted"));
+ },
+ onError: () => toast.error(t("toastDeleteError")),
+ });
+
+ const [editing, setEditing] = useState(false);
+ const [form, setForm] = useState({
+ foodType: "",
+ feedingTimes: "",
+ dailyAmountG: "",
+ foodBrand: "",
+ specialDiet: "",
+ });
+
+ const openEdit = () => {
+ setForm({
+ foodType: info?.foodType ?? "",
+ feedingTimes: info?.feedingTimes?.toString() ?? "",
+ dailyAmountG: info?.dailyAmountG?.toString() ?? "",
+ foodBrand: info?.foodBrand ?? "",
+ specialDiet: info?.specialDiet ?? "",
+ });
+ setEditing(true);
+ };
+
+ const handleSave = () => {
+ saveMutation.mutate({
+ petId,
+ foodType: form.foodType.trim() || null,
+ feedingTimes: form.feedingTimes ? parseInt(form.feedingTimes) : null,
+ dailyAmountG: form.dailyAmountG ? parseInt(form.dailyAmountG) : null,
+ foodBrand: form.foodBrand.trim() || null,
+ specialDiet: form.specialDiet.trim() || null,
+ });
+ };
+
+ if (isLoading) return null;
+
+ return (
+
+
+
+
+ {t("sectionTitle")}
+
+ {info && !editing && (
+
+
+
+
+ )}
+ {!info && !editing && (
+
+ )}
+
+
+ {/* View mode */}
+ {info && !editing && (
+
+ {info.foodType && (
+
+
{t("labelFoodType")}
+
{info.foodType}
+
+ )}
+ {(info.feedingTimes != null || info.dailyAmountG != null) && (
+
+
{t("labelFeedingTimes")}
+
+ {info.feedingTimes != null && t("timesPerDay", { count: info.feedingTimes })}
+ {info.feedingTimes != null && info.dailyAmountG != null && " · "}
+ {info.dailyAmountG != null && t("gramsPerDay", { amount: info.dailyAmountG })}
+
+
+ )}
+ {info.foodBrand && (
+
+
{t("labelBrand")}
+
{info.foodBrand}
+
+ )}
+ {info.specialDiet && (
+
+
{t("labelSpecialDiet")}
+
{info.specialDiet}
+
+ )}
+
+ )}
+
+ {/* Edit mode */}
+ {editing && (
+
+
+
+ setForm((f) => ({ ...f, foodType: e.target.value }))}
+ />
+
+
+
+
+ setForm((f) => ({ ...f, foodBrand: e.target.value }))}
+ />
+
+
+
+
+
+
+
+
+
+ )}
+
+ {!info && !editing && (
+
{t("noInfo")}
+ )}
+
+ );
+}
diff --git a/src/components/health/HealthDashboard.tsx b/src/components/health/HealthDashboard.tsx
index bc7b186..ae5d22b 100644
--- a/src/components/health/HealthDashboard.tsx
+++ b/src/components/health/HealthDashboard.tsx
@@ -2,6 +2,7 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useTranslations } 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";
@@ -28,6 +29,7 @@ import {
} from "@/components/ui/select";
import { WeightChart } from "@/components/health/WeightChart";
import { EmergencyVetSection } from "@/components/health/EmergencyVetSection";
+import { FeedingSection } from "@/components/health/FeedingSection";
interface HealthDashboardProps {
petId: string;
@@ -55,6 +57,7 @@ function isOverdue(date: Date | string): boolean {
// ── Weight Tab ────────────────────────────────────────────────────────────────
function WeightTab({ petId }: { petId: string }) {
+ const t = useTranslations("Health");
const trpc = useTRPC();
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
@@ -68,27 +71,27 @@ function WeightTab({ petId }: { petId: string }) {
...trpc.health.addWeightLog.mutationOptions(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.health.listWeightLogs.queryKey({ petId }) });
- toast.success("Weight entry added");
+ toast.success(t("weight.toastAdded"));
setShowForm(false);
setWeightKg("");
setNotes("");
},
- onError: () => toast.error("Failed to save weight entry"),
+ onError: () => toast.error(t("weight.toastError")),
});
const deleteMutation = useMutation({
...trpc.health.deleteWeightLog.mutationOptions(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.health.listWeightLogs.queryKey({ petId }) });
- toast.success("Entry deleted");
+ toast.success(t("weight.toastDeleted"));
},
- onError: () => toast.error("Failed to delete entry"),
+ onError: () => toast.error(t("weight.toastDeleteError")),
});
const handleAdd = () => {
const kg = parseFloat(weightKg);
if (!date || isNaN(kg) || kg <= 0) {
- toast.error("Please fill in date and a valid weight");
+ toast.error(t("weight.toastValidation"));
return;
}
addMutation.mutate({ petId, date, weightKg: kg, notes: notes.trim() || undefined });
@@ -97,9 +100,11 @@ function WeightTab({ petId }: { petId: string }) {
return (
-
{data.length} {data.length === 1 ? "entry" : "entries"}
+
+ {data.length === 1 ? t("weight.singular") : t("weight.plural", { count: data.length })}
+
@@ -107,31 +112,31 @@ function WeightTab({ petId }: { petId: string }) {
@@ -140,10 +145,9 @@ function WeightTab({ petId }: { petId: string }) {
{isLoading &&
}
{!isLoading && data.length === 0 && (
-
No weight entries yet
+
{t("weight.noEntries")}
)}
- {/* Chart — shown when 2+ entries exist */}
{!isLoading && data.length >= 2 &&
}
{data.length > 0 && (
@@ -176,6 +180,7 @@ function WeightTab({ petId }: { petId: string }) {
// ── Vet Visits Tab ────────────────────────────────────────────────────────────
function VetVisitsTab({ petId }: { petId: string }) {
+ const t = useTranslations("Health");
const trpc = useTRPC();
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
@@ -190,27 +195,27 @@ function VetVisitsTab({ petId }: { petId: string }) {
...trpc.health.addVetVisit.mutationOptions(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.health.listVetVisits.queryKey({ petId }) });
- toast.success("Vet visit added");
+ toast.success(t("vet.toastAdded"));
setShowForm(false);
setReason("");
setVetName("");
setNotes("");
},
- onError: () => toast.error("Failed to save vet visit"),
+ onError: () => toast.error(t("vet.toastError")),
});
const deleteMutation = useMutation({
...trpc.health.deleteVetVisit.mutationOptions(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.health.listVetVisits.queryKey({ petId }) });
- toast.success("Entry deleted");
+ toast.success(t("vet.toastDeleted"));
},
- onError: () => toast.error("Failed to delete entry"),
+ onError: () => toast.error(t("vet.toastDeleteError")),
});
const handleAdd = () => {
if (!date || !reason.trim()) {
- toast.error("Please fill in date and reason");
+ toast.error(t("vet.toastValidation"));
return;
}
addMutation.mutate({
@@ -225,9 +230,11 @@ function VetVisitsTab({ petId }: { petId: string }) {
return (
-
{data.length} {data.length === 1 ? "visit" : "visits"}
+
+ {data.length === 1 ? t("vet.singular") : t("vet.plural", { count: data.length })}
+
@@ -235,26 +242,26 @@ function VetVisitsTab({ petId }: { petId: string }) {
@@ -263,7 +270,7 @@ function VetVisitsTab({ petId }: { petId: string }) {
{isLoading &&
}
{!isLoading && data.length === 0 && (
-
No vet visits yet
+
{t("vet.noEntries")}
)}
{data.length > 0 && (
@@ -299,6 +306,7 @@ function VetVisitsTab({ petId }: { petId: string }) {
// ── Vaccines Tab ──────────────────────────────────────────────────────────────
function VaccinesTab({ petId }: { petId: string }) {
+ const t = useTranslations("Health");
const trpc = useTRPC();
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
@@ -313,27 +321,27 @@ function VaccinesTab({ petId }: { petId: string }) {
...trpc.health.addVaccine.mutationOptions(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.health.listVaccines.queryKey({ petId }) });
- toast.success("Vaccine added");
+ toast.success(t("vaccines.toastAdded"));
setShowForm(false);
setName("");
setNextDueDate("");
setNotes("");
},
- onError: () => toast.error("Failed to save vaccine"),
+ onError: () => toast.error(t("vaccines.toastError")),
});
const deleteMutation = useMutation({
...trpc.health.deleteVaccine.mutationOptions(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.health.listVaccines.queryKey({ petId }) });
- toast.success("Entry deleted");
+ toast.success(t("vaccines.toastDeleted"));
},
- onError: () => toast.error("Failed to delete entry"),
+ onError: () => toast.error(t("vaccines.toastDeleteError")),
});
const handleAdd = () => {
if (!name.trim() || !dateGiven) {
- toast.error("Please fill in vaccine name and date given");
+ toast.error(t("vaccines.toastValidation"));
return;
}
addMutation.mutate({
@@ -348,36 +356,38 @@ function VaccinesTab({ petId }: { petId: string }) {
return (
-
{data.length} {data.length === 1 ? "vaccine" : "vaccines"}
+
+ {data.length === 1 ? t("vaccines.singular") : t("vaccines.plural", { count: data.length })}
+
{showForm && (
@@ -386,7 +396,7 @@ function VaccinesTab({ petId }: { petId: string }) {
{isLoading &&
}
{!isLoading && data.length === 0 && (
-
No vaccines recorded yet
+
{t("vaccines.noEntries")}
)}
{data.length > 0 && (
@@ -400,17 +410,17 @@ function VaccinesTab({ petId }: { petId: string }) {
{vac.name}
{overdue && (
- Overdue
+ {t("vaccines.badgeOverdue")}
)}
{vac.nextDueDate && !overdue && (
- Due {formatDate(vac.nextDueDate)}
+ {t("vaccines.badgeDue", { date: formatDate(vac.nextDueDate) })}
)}
- Given {formatDate(vac.dateGiven)}{vac.notes ? ` · ${vac.notes}` : ""}
+ {t("vaccines.textGiven", { date: formatDate(vac.dateGiven) })}{vac.notes ? ` · ${vac.notes}` : ""}