diff --git a/DEBUG-List.md b/DEBUG-List.md new file mode 100644 index 0000000..3dfa728 --- /dev/null +++ b/DEBUG-List.md @@ -0,0 +1,14 @@ +Debug: + +Health - Share Card - Downloadbar machen als PDF Ansicht, mit Profil Foto und allen Health Daten übersichtlich aufgegliedert. + +jeweils mit Farblichen Trennern oder Rahmen. So wäre es möglich bei einem Arztwechsel, gleich alle Daten mitnehmen zu können. + +Oder für den Notfall ausgedruckt dabei zu haben. + + + +Health - Share Card - Der Link wird nicht generiert, hier müsste ein Unique link erstellt werden, mit einem Verfallsdatum in der regel sollten 14 Tage ausreichen, aber man soll sich das selbst einstellen können, 7,13,30,60,90 Tage zur Auswahl. + + + diff --git a/README.md b/README.md index 10ae39c..5a41e6e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ A social platform where pets are the stars. Dogs, cats, and birds get their own - The social feed uses a **Redis fan-out-on-write** pattern: on each new post, a sorted set entry is written into every follower's feed inbox. `feed.getFeed` reads from Redis and hydrates with Prisma. Falls back to Postgres when Redis is unavailable. - Authentication is handled by **Clerk** (owner accounts). Pet profiles are app-domain entities in Postgres, not Clerk users. - Short-form video is handled by **Mux**: client uploads directly to a signed Mux URL → Mux transcodes and serves HLS via `mux-player-react`. The `getByPostId` query self-heals in dev by polling the Mux API directly, so webhooks are not required during local development. +- **Health data is owner-private.** The only publicly accessible health surface is the shareable health card (token URL with expiry), designed for vets, friends, and pet sitters. --- @@ -45,16 +46,21 @@ src/ pets/[petId]/edit/ pets/[petId]/followers/ pets/[petId]/following/ + pets/[petId]/health/ # Owner-only health dashboard (weight, vet visits, vaccines) search/ # Pet + People + Hashtag search + messages/ # DM inbox list + messages/[conversationId]/ # Conversation view onboarding/ # Pet creation onboarding (auth)/ # Clerk-rendered sign-in / sign-up + health-card/[token]/ # Public shareable health card (no login required) api/ trpc/ # tRPC HTTP handler webhooks/ mux/ # Mux webhook handler (video.asset.ready etc.) components/ feed/ # PostCard, MilestoneCard, RepostCard, FeedList, SkeletonCard - # PawButton (🐾 reaction), PawBackButton (repost), CommentSheet + # PawButton (paw reaction), PawBackButton (repost), CommentSheet + health/ # HealthDashboard (tabs), WeightChart (SVG bezier), PrintButton, AutoPrint layout/ # Sidebar (with NotificationBell), MobileNav notifications/ # NotificationBell (badge + count) pet/ # AvatarUpload, PetForm, PetListItem @@ -65,7 +71,7 @@ src/ social/ # FollowButton stories/ # PawRing, StoryViewer, StoryTray, StoryForm ui/ # shadcn component copies (button, dialog, sheet, etc.) - video/ # VideoCard (polls PROCESSING → plays READY via MuxPlayer) + video/ # VideoCard (polls PROCESSING -> plays READY via MuxPlayer) context/ ActivePetContext.tsx # Global active pet state (pet switcher) lib/ @@ -73,7 +79,7 @@ src/ feed-helpers.ts # fanOutPost, backfillOnFollow (Redis) hashtag-helpers.ts # extractHashtags, saveHashtags media-url.ts # CDN URL construction for post/story media - milestone-meta.ts # MilestoneType → Lucide icon + label map + milestone-meta.ts # MilestoneType -> Lucide icon + label map milestone-thresholds.ts # Follower count thresholds for auto-milestones mux.ts # Mux Node SDK singleton + MUX_WEBHOOK_SECRET parse-caption.ts # Tokenises captions into text + #hashtag segments @@ -87,13 +93,13 @@ src/ query-client.ts # TanStack Query client factory routers/ _app.ts # Root router - pets.ts # CRUD for pet profiles + pets.ts # CRUD for pet profiles + getProfile (no ownership check) posts.ts # Photo posts (create, updateCaption, delete, byPetId) stories.ts # 24h stories (create, listActive, hasActiveStory, recordView, getViewers) milestones.ts # Milestone posts (create, listByPet) feed.ts # Home feed (getFeed via Redis + Prisma hydration) follows.ts # Pet-to-pet follow graph - reactions.ts # 🐾 paw reactions (toggle, count per post) + reactions.ts # Paw reactions (toggle, count per post) comments.ts # Flat comments (create, delete, listByPost) reposts.ts # Paw-Back repost with attribution explore.ts # Explore page (species/breed filter) @@ -103,6 +109,8 @@ src/ blocks.ts # Block a pet reports.ts # Report a post or pet media.ts # Avatar presigned upload (getPresignedUrl, confirmUpload) + messages.ts # DM conversations (create, list, send, markRead) + health.ts # Weight logs, vet visits, vaccines, shareable health card __tests__/ helpers/ prisma-mock.ts # Typed mock PrismaClient factory @@ -117,7 +125,8 @@ src/ middleware.ts # Next.js middleware (Clerk auth guard) prisma/ schema.prisma # Full DB schema (Owner, Pet, Post, Story, Follow, Block, Report, - # Reaction, Comment, Repost, Hashtag, Notification, VideoPost) + # Reaction, Comment, Repost, Hashtag, Notification, VideoPost, + # Conversation, Message, WeightLog, VetVisit, Vaccine, HealthCard) seed.ts # Species + breed seed (Dog/Cat/Bird) supabase/ migrations/ @@ -155,17 +164,17 @@ cp .env.example .env.local | Variable | Where to find it | |---|---| -| `DATABASE_URL` | Supabase → Project Settings → Database → Connection string (pooled, port 6543) | -| `DIRECT_URL` | Supabase → Project Settings → Database → Connection string (direct, port 5432) | -| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk Dashboard → API Keys | -| `CLERK_SECRET_KEY` | Clerk Dashboard → API Keys | -| `NEXT_PUBLIC_SUPABASE_URL` | Supabase → Project Settings → API → Project URL | -| `SUPABASE_SERVICE_ROLE_KEY` | Supabase → Project Settings → API → service_role key | -| `UPSTASH_REDIS_REST_URL` | Upstash Console → Redis database → REST URL | -| `UPSTASH_REDIS_REST_TOKEN` | Upstash Console → Redis database → REST Token | -| `MUX_TOKEN_ID` | Mux Dashboard → Settings → API Access Tokens | -| `MUX_TOKEN_SECRET` | Mux Dashboard → Settings → API Access Tokens | -| `MUX_WEBHOOK_SECRET` | Mux Dashboard → Settings → Webhooks → Signing secret | +| `DATABASE_URL` | Supabase -> Project Settings -> Database -> Connection string (pooled, port 6543) | +| `DIRECT_URL` | Supabase -> Project Settings -> Database -> Connection string (direct, port 5432) | +| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk Dashboard -> API Keys | +| `CLERK_SECRET_KEY` | Clerk Dashboard -> API Keys | +| `NEXT_PUBLIC_SUPABASE_URL` | Supabase -> Project Settings -> API -> Project URL | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase -> Project Settings -> API -> service_role key | +| `UPSTASH_REDIS_REST_URL` | Upstash Console -> Redis database -> REST URL | +| `UPSTASH_REDIS_REST_TOKEN` | Upstash Console -> Redis database -> REST Token | +| `MUX_TOKEN_ID` | Mux Dashboard -> Settings -> API Access Tokens | +| `MUX_TOKEN_SECRET` | Mux Dashboard -> Settings -> API Access Tokens | +| `MUX_WEBHOOK_SECRET` | Mux Dashboard -> Settings -> Webhooks -> Signing secret | > `SUPABASE_SERVICE_ROLE_KEY` and `MUX_TOKEN_SECRET` are server-only secrets. They are imported exclusively in `server-only` modules and never exposed to the client. @@ -195,11 +204,11 @@ Enable Row Level Security on all tables (run once in Supabase SQL Editor): ### 4. Supabase Storage -Create a public bucket named `pet-avatars` in Supabase Storage (Dashboard → Storage → New bucket). +Create a public bucket named `pet-avatars` in Supabase Storage (Dashboard -> Storage -> New bucket). ### 5. Mux webhook (production only) -In Mux Dashboard → Settings → Webhooks, add an endpoint pointing to `https://your-domain.com/api/webhooks/mux`. Subscribe to: +In Mux Dashboard -> Settings -> Webhooks, add an endpoint pointing to `https://your-domain.com/api/webhooks/mux`. Subscribe to: - `video.upload.asset_created` - `video.asset.ready` - `video.asset.errored` @@ -241,7 +250,7 @@ Open [http://localhost:3000](http://localhost:3000). The app redirects unauthent Clerk User (owner) └── owns 1..n Pet profiles (Postgres) └── Pet is the social identity (posts, follows, stories, milestones, - reactions, comments, reposts, notifications, videos) + reactions, comments, reposts, notifications, videos, DMs, health data) ``` An owner can switch between their pets via `ActivePetContext`. All tRPC mutations assert `pet.ownerId === ctx.userId` before writing. @@ -249,19 +258,19 @@ An owner can switch between their pets via `ActivePetContext`. All tRPC mutation ### Media pipeline — Photos ``` -Client → tRPC getPresignedUrl → Supabase Storage signed URL -Client → XHR PUT directly to signed URL (bytes never hit Next.js server) -Client → tRPC create/confirmUpload (writes storage key to DB) -Feed → getMediaUrl(storageKey) → Supabase public CDN URL +Client -> tRPC getPresignedUrl -> Supabase Storage signed URL +Client -> XHR PUT directly to signed URL (bytes never hit Next.js server) +Client -> tRPC create/confirmUpload (writes storage key to DB) +Feed -> getMediaUrl(storageKey) -> Supabase public CDN URL ``` ### Media pipeline — Video (Mux) ``` -Client → tRPC videos.createUpload → Mux direct upload URL + PROCESSING Post row -Client → XHR PUT directly to Mux URL (bytes never hit Next.js server) -Mux → transcodes → fires webhook → /api/webhooks/mux → updates VideoPost (READY + playbackId) -VideoCard polls videos.getByPostId every 5s → transitions from spinner to MuxPlayer +Client -> tRPC videos.createUpload -> Mux direct upload URL + PROCESSING Post row +Client -> XHR PUT directly to Mux URL (bytes never hit Next.js server) +Mux -> transcodes -> fires webhook -> /api/webhooks/mux -> updates VideoPost (READY + playbackId) +VideoCard polls videos.getByPostId every 5s -> transitions from spinner to MuxPlayer ``` > In dev (no webhook): `getByPostId` checks the Mux API directly when the status is `PROCESSING` and updates the DB on the fly. No ngrok required. @@ -274,21 +283,25 @@ Post created └── for each follower: ZADD feed:{followerPetId} score=timestamp member=postId feed.getFeed({ petId, cursor }) - └── ZRANGE feed:{petId} → postIds - └── prisma.post.findMany({ where: { id: { in: postIds } } }) → hydrated posts + └── ZRANGE feed:{petId} -> postIds + └── prisma.post.findMany({ where: { id: { in: postIds } } }) -> hydrated posts └── Fallback (Redis unavailable): Postgres query on follows + own posts ``` ### Notification triggers (fire-and-forget) ``` -follows.follow → FOLLOW notification to followeePet -reactions.toggle → REACTION notification to post owner (skip if own post) -comments.create → COMMENT notification to post owner (skip if own post) +follows.follow -> FOLLOW notification to followeePet +reactions.toggle -> REACTION notification to post owner (skip if own post) +comments.create -> COMMENT notification to post owner (skip if own post) ``` All triggers are wrapped in `.catch(() => {})` — they never crash the parent mutation. +### Health data privacy + +All health data (weight logs, vet visits, vaccines) is owner-private. The only publicly accessible health surface is the **HealthCard**: a token URL with a user-selected expiry (7 / 14 / 30 / 60 / 90 days). The card page checks expiry server-side before rendering. Opening the card with `?print=1` auto-triggers `window.print()` for browser-native PDF export — no PDF library required. + ### Story expiry (lazy-delete) Stories are never deleted in real time. All queries filter `expiresAt > NOW()`. A weekly cleanup job purges expired rows. Story views are retained 30 days for analytics. @@ -331,7 +344,7 @@ The viewer identity comes from `ActivePetContext` inside `PawRing` itself — ca ### Phase 3 — Engagement & Discovery ✅ -- **🐾 Paw reactions:** single reaction per post, toggle on/off, live count on PostCard +- **Paw reactions:** single reaction per post, toggle on/off, live count on PostCard - **Comments:** flat thread, create/delete own, CommentSheet drawer - **Paw-Back (Repost):** repost with attribution header in feed (PostType.REPOST) - **Explore page:** species tabs + breed filter, random fresh content @@ -356,13 +369,28 @@ The viewer identity comes from `ActivePetContext` inside `PawRing` itself — ca - Mux webhook handler at `/api/webhooks/mux` for production (`video.upload.asset_created`, `video.asset.ready`, `video.asset.errored`) - Video posts appear in feed, profile grid, and notifications alongside photo posts -### Phase 6 — Messaging (planned) +### Phase 6 — Messaging ✅ -Direct messages between pet owners via Supabase Realtime. +- `Conversation` + `Message` models; `DmPolicy` enum (`EVERYONE | FOLLOWERS_ONLY`) on Pet +- Pet-to-pet DMs: inbox sorted by last message, conversation view with read/unread tracking +- `readAt` timestamp per message; unread count badge in Sidebar + MobileNav +- DM policy enforced server-side: FOLLOWERS_ONLY pets reject messages from non-followers +- Conversation deduplication: `petAId` always < `petBId` (sorted on create) — one row per pair -### Phase 7 — Health (planned) +### Phase 7 — Health & Vet Tracking ✅ -Vet records, vaccination logs, weight tracking, shareable health card. +- `WeightLog`, `VetVisit`, `Vaccine`, `HealthCard` models — all owner-private +- Health dashboard at `/pets/[petId]/health` with three tabs: Weight / Tierarztbesuche / Impfungen +- Weight trend chart: pure SVG bezier curve, no library, PawFeed orange-500 design, hover tooltips; shown when 2+ entries exist +- `WeightChart` is an extracted, reusable client component (`src/components/health/WeightChart.tsx`) +- Overdue vaccine badge (AlertTriangle + destructive variant) when `nextDueDate` is in the past +- **Shareable health card** at `/health-card/[token]` — no login required + - Expiry selectable per share: 7 / 14 / 30 / 60 / 90 days (default 14) + - Expired links show a German-language expiry message with the exact date + - Link can be regenerated (old token invalidated) at any time + - Weight trend chart included in the card view + - PDF export: "Als PDF öffnen / drucken" opens the card with `?print=1` which auto-triggers `window.print()` — browser-native, no PDF library + - `print:hidden` Tailwind variant hides nav chrome during print --- @@ -383,7 +411,7 @@ npx vitest # watch mode |---|---| | `DATABASE_URL` must use the **pooled** connection (port 6543, `?pgbouncer=true`) | Prisma uses this at runtime. `DIRECT_URL` (port 5432) is only for `prisma db push` / migrations. | | Prisma on Windows requires `DIRECT_URL` set in `.env.local` | The Prisma CLI reads `.env.local` via a custom `prisma.config.ts`. If `npx prisma db push` hangs, check `DIRECT_URL` is set. | -| After a Prisma schema change, **always restart the dev server** | The Next.js dev server caches the Prisma client in memory. A running server started before `prisma generate` will not see new enum values (e.g. `PostType.VIDEO`). | +| After a Prisma schema change, **always restart the dev server** | The Next.js dev server caches the Prisma client in memory. A running server started before `prisma generate` will not see new models. | | Supabase service_role key is never client-facing | All files that import `supabase-storage.ts` must be `server-only`. The client only ever receives signed URLs. | | `asChild` prop is not available in the base-nova shadcn preset | Do not use `asChild` on any shadcn component. This affects `PopoverTrigger`, `DropdownMenuItem`, etc. Style the trigger element directly. | | StoryView records are NOT cascade-deleted when a story expires | This is intentional (D-08): view analytics are retained 30 days. A separate cleanup job will purge old views. | diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dbda604..9cb5d5c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -388,12 +388,13 @@ model Vaccine { } model HealthCard { - id String @id @default(cuid()) - pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade) - petId String @unique - token String @unique @default(cuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade) + petId String @unique + token String @unique @default(cuid()) + expiresAt DateTime? // null = never expires; set on create/regenerate + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } // ─── Phase 4 Enums ─────────────────────────────────────────────────────────── diff --git a/src/app/health-card/[token]/page.tsx b/src/app/health-card/[token]/page.tsx index caf9033..2b77a13 100644 --- a/src/app/health-card/[token]/page.tsx +++ b/src/app/health-card/[token]/page.tsx @@ -5,9 +5,13 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { getAvatarUrl } from "@/lib/avatar-url"; +import { PrintButton } from "@/components/health/PrintButton"; +import { AutoPrint } from "@/components/health/AutoPrint"; +import { WeightChart } from "@/components/health/WeightChart"; interface PageProps { params: Promise<{ token: string }>; + searchParams: Promise<{ print?: string }>; } function formatDate(d: Date | string) { @@ -27,8 +31,10 @@ function isOverdue(date: Date | string): boolean { return new Date(date) < new Date(); } -export default async function HealthCardPage({ params }: PageProps) { +export default async function HealthCardPage({ params, searchParams }: PageProps) { const { token } = await params; + const { print } = await searchParams; + const autoPrint = print === "1"; const card = await prisma.healthCard.findUnique({ where: { token }, @@ -47,16 +53,48 @@ export default async function HealthCardPage({ params }: PageProps) { if (!card) notFound(); + // Expired link + if (card.expiresAt && card.expiresAt < new Date()) { + return ( +
+
+
+
+ +
+
+

Link abgelaufen

+

+ Dieser Health-Card-Link ist am{" "} + {formatDate(card.expiresAt)} abgelaufen. + Bitte den Besitzer um einen neuen Link. +

+
+ + PawFeed +
+
+
+ ); + } + const { pet } = card; const avatarUrl = getAvatarUrl(pet.avatarKey); return (
+ {autoPrint && } {/* Header bar */} -
+
@@ -80,21 +118,32 @@ export default async function HealthCardPage({ params }: PageProps) { {/* Vaccines */} {pet.vaccines.length > 0 && (
-

Vaccines

+

+ + Impfungen +

{pet.vaccines.map((vac) => { const overdue = vac.nextDueDate && isOverdue(vac.nextDueDate); return ( -
+

{vac.name}

-

Given {formatDate(vac.dateGiven)}

+

+ Gegeben: {formatDate(vac.dateGiven)} + {vac.notes ? ` · ${vac.notes}` : ""} +

{vac.nextDueDate && ( - + {overdue && } - {overdue ? "Overdue" : `Due ${formatDate(vac.nextDueDate)}`} + {overdue + ? `Überfällig seit ${formatDate(vac.nextDueDate)}` + : `Fällig ${formatDate(vac.nextDueDate)}`} )}
@@ -108,25 +157,43 @@ export default async function HealthCardPage({ params }: PageProps) { {/* Weight history */} {pet.weightLogs.length > 0 && (
-

Weight History

-
- {pet.weightLogs.map((log) => ( -
-

{formatDate(log.date)}

-
- {log.notes &&

{log.notes}

} -

{formatWeight(log.weightGrams)}

-
-
- ))} -
+

+ + Gewichtsverlauf +

+ + + + + + + + + + {pet.weightLogs.map((log) => ( + + + + + + ))} + +
DatumGewichtNotiz
{formatDate(log.date)} + {formatWeight(log.weightGrams)} + {log.notes ?? ""}
+ {pet.weightLogs.length >= 2 && ( + + )}
)} {/* Vet visits */} {pet.vetVisits.length > 0 && (
-

Vet Visits

+

+ + Tierarztbesuche +

{pet.vetVisits.map((visit, i) => (
@@ -134,7 +201,9 @@ export default async function HealthCardPage({ params }: PageProps) {

{visit.reason}

-

{formatDate(visit.date)}

+

+ {formatDate(visit.date)} +

{visit.vetName && (

{visit.vetName}

@@ -149,16 +218,19 @@ export default async function HealthCardPage({ params }: PageProps) {
)} - {pet.vaccines.length === 0 && pet.weightLogs.length === 0 && pet.vetVisits.length === 0 && ( -
-

No health records have been added yet.

-
- )} + {pet.vaccines.length === 0 && + pet.weightLogs.length === 0 && + pet.vetVisits.length === 0 && ( +
+

Noch keine Gesundheitsdaten vorhanden.

+
+ )} {/* Footer */} -

- Generated via PawFeed · {new Date().toLocaleDateString("de-DE")} -

+
+ Erstellt via PawFeed · {new Date().toLocaleDateString("de-DE")} + {card.expiresAt && Gültig bis {formatDate(card.expiresAt)}} +
); diff --git a/src/components/feed/CommentSheet.tsx b/src/components/feed/CommentSheet.tsx new file mode 100644 index 0000000..260a69b --- /dev/null +++ b/src/components/feed/CommentSheet.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useState, useRef, useCallback, useEffect } from "react"; +import { Trash2, Loader2 } from "lucide-react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useTRPC } from "@/trpc/client"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { getAvatarUrl } from "@/lib/avatar-url"; + +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 "just now"; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + if (diffDays < 7) return `${diffDays}d`; + return new Date(date).toLocaleDateString(); +} + +interface CommentSheetProps { + postId: string; + petId: string; // active pet (commenter) + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CommentSheet({ postId, petId, open, onOpenChange }: CommentSheetProps) { + const trpc = useTRPC(); + 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("Couldn't post comment. Please try again."), + }) + ); + + 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("Couldn't delete comment. Please try again."), + }) + ); + + 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 ( + + + + Comments + + + + + + {isLoading ? ( +
+ +
+ ) : comments.length === 0 ? ( +

+ No comments yet — be the first! +

+ ) : ( +
+ {comments.map((comment) => { + const isOwn = comment.pet.id === petId; + const avatarUrl = getAvatarUrl(comment.pet.avatarKey); + return ( +
+ + {avatarUrl && ( + {comment.pet.name} + )} + + {comment.pet.name[0]?.toUpperCase() ?? "?"} + + +
+
+ + {comment.pet.name} + + + {formatRelativeTime(comment.createdAt)} + +
+

{comment.body}

+
+ {isOwn && ( + + )} +
+ ); + })} +
+ )} +
+ + + + {/* Comment input — sticky at bottom */} +
+