fix: resolve 5 bugs from dev testing

- PostCard: add variant="detail" (object-contain + black bg) for profile sheet — full image in original format
- VideoCard: getByPostId polls Mux API directly when PROCESSING, self-heals without webhooks in dev
- PawRing: gray ring for all-seen stories, orange for unseen; reads viewerPetId from ActivePetContext
- Search: add People tab via new search.byOwner (Clerk username search → owner's pets)
- Notifications: rows now navigate to actor profile (FOLLOW) or pet profile (REACTION/COMMENT)
- Notifications router: include post.petId for navigation target

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-14 08:41:46 +02:00
1 parent 60366691e8
commit da0f9f2bb7
9 files changed
+261 -59

No files matched your search

+18 -19
View File
@@ -2,6 +2,7 @@
import { useEffect } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Bell, UserPlus, Heart, MessageCircle } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
@@ -34,25 +35,10 @@ function notificationIcon(type: NotificationType) {
}
}
function notificationText(
type: NotificationType,
actorName: string | undefined,
commentBody: string | undefined
): string {
const name = actorName ?? "Someone";
switch (type) {
case "FOLLOW": return `${name} started following you`;
case "REACTION": return `${name} reacted to your post`;
case "COMMENT": return commentBody
? `${name} commented: "${commentBody.slice(0, 60)}${commentBody.length > 60 ? "…" : ""}"`
: `${name} commented on your post`;
case "MESSAGE": return `${name} sent you a message`;
}
}
export default function NotificationsPage() {
const trpc = useTRPC();
const queryClient = useQueryClient();
const router = useRouter();
const { activePetId } = useActivePet();
const { data, isLoading } = useQuery({
@@ -63,7 +49,6 @@ export default function NotificationsPage() {
const markAllReadMutation = useMutation(
trpc.notifications.markAllRead.mutationOptions({
onSuccess: () => {
// Invalidate badge count
void queryClient.invalidateQueries(
trpc.notifications.unreadCount.queryOptions({ petId: activePetId ?? "" })
);
@@ -71,7 +56,6 @@ export default function NotificationsPage() {
})
);
// Mark all read when landing on the page
useEffect(() => {
if (activePetId && data && data.notifications.some((n) => !n.read)) {
markAllReadMutation.mutate({ petId: activePetId });
@@ -81,6 +65,18 @@ export default function NotificationsPage() {
const notifications = data?.notifications ?? [];
function getHref(notif: (typeof notifications)[number]): string | null {
switch (notif.type) {
case "FOLLOW":
return notif.actorPet ? `/pets/${notif.actorPet.id}` : null;
case "REACTION":
case "COMMENT":
return notif.post?.petId ? `/pets/${notif.post.petId}` : null;
case "MESSAGE":
return null; // Phase 6
}
}
return (
<div className="max-w-[640px] mx-auto px-4 py-8">
<h1 className="text-xl font-semibold mb-6 flex items-center gap-2">
@@ -119,13 +115,15 @@ export default function NotificationsPage() {
const postThumbUrl = notif.post?.images?.[0]
? getMediaUrl(notif.post.images[0].storageKey)
: null;
const href = getHref(notif);
return (
<li
key={notif.id}
onClick={href ? () => router.push(href) : undefined}
className={`flex items-start gap-3 p-3 rounded-lg transition-colors ${
!notif.read ? "bg-orange-500/5" : "hover:bg-muted/50"
}`}
} ${href ? "cursor-pointer hover:bg-muted/70" : ""}`}
>
{/* Unread dot */}
<div className="shrink-0 mt-3">
@@ -158,6 +156,7 @@ export default function NotificationsPage() {
<Link
href={`/pets/${notif.actorPet.id}`}
className="font-semibold hover:underline"
onClick={(e) => e.stopPropagation()}
>
{notif.actorPet.name}
</Link>
+102 -4
View File
@@ -2,7 +2,7 @@
import { useState, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { Search, Layers2, Loader2 } from "lucide-react";
import { Search, Layers2, Loader2, Users } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { Input } from "@/components/ui/input";
@@ -29,7 +29,7 @@ export default function SearchPage() {
const trimmed = debouncedQuery.trim();
const petsEnabled = trimmed.length >= 2;
// Hashtag: strip leading # then require at least 1 char
const peopleEnabled = trimmed.length >= 2;
const normalizedTag = trimmed.replace(/^#/, "").toLowerCase();
const tagsEnabled = normalizedTag.length >= 1;
@@ -38,6 +38,11 @@ export default function SearchPage() {
);
const pets = petResults?.pets ?? [];
const { data: ownerResults, isLoading: loadingPeople } = useQuery(
trpc.search.byOwner.queryOptions({ query: trimmed }, { enabled: peopleEnabled })
);
const owners = ownerResults?.owners ?? [];
const { data: tagResults, isLoading: loadingTags } = useQuery(
trpc.search.byHashtag.queryOptions(
{ tag: normalizedTag },
@@ -50,11 +55,10 @@ export default function SearchPage() {
<div className="max-w-[640px] mx-auto px-4 py-6">
<h1 className="text-xl font-semibold mb-4">Search</h1>
{/* Search input */}
<div className="relative mb-4">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" aria-hidden="true" />
<Input
placeholder="Search pets or hashtags…"
placeholder="Search pets, people or hashtags…"
value={query}
onChange={handleQueryChange}
className="pl-9"
@@ -65,6 +69,7 @@ export default function SearchPage() {
<Tabs defaultValue="pets">
<TabsList className="mb-4">
<TabsTrigger value="pets">Pets</TabsTrigger>
<TabsTrigger value="people">People</TabsTrigger>
<TabsTrigger value="tags">Tags</TabsTrigger>
</TabsList>
@@ -125,6 +130,99 @@ export default function SearchPage() {
)}
</TabsContent>
{/* People tab — search owners by Clerk username */}
<TabsContent value="people">
{!peopleEnabled ? (
<p className="text-sm text-muted-foreground text-center py-10">
Type at least 2 characters to search
</p>
) : loadingPeople ? (
<div className="flex justify-center py-10">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : owners.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-10 text-muted-foreground">
<Users className="h-8 w-8 opacity-30" />
<p className="text-sm">No people found for &ldquo;{trimmed}&rdquo;</p>
</div>
) : (
<div className="space-y-5">
{owners.map((owner) => {
const displayName = owner.username
? `@${owner.username}`
: [owner.firstName, owner.lastName].filter(Boolean).join(" ") || "Unknown";
const subName = owner.username && (owner.firstName || owner.lastName)
? [owner.firstName, owner.lastName].filter(Boolean).join(" ")
: null;
return (
<div key={owner.id}>
{/* Owner header */}
<div className="flex items-center gap-3 px-3 py-2">
<Avatar className="w-10 h-10 shrink-0">
{owner.imageUrl && (
<AvatarImage src={owner.imageUrl} alt={displayName} />
)}
<AvatarFallback className="text-sm bg-muted">
{displayName[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold truncate">{displayName}</p>
{subName && (
<p className="text-xs text-muted-foreground truncate">{subName}</p>
)}
</div>
</div>
{/* Owner's pets */}
{owner.pets.length > 0 && (
<div className="ml-4 border-l border-border pl-4 space-y-1">
{(owner.pets as Array<{
id: string;
name: string;
avatarKey: string | null;
species: { name: string };
breed: { name: string } | null;
_count: { followers: number };
}>).map((pet) => {
const avatarUrl = getAvatarUrl(pet.avatarKey);
return (
<div
key={pet.id}
className="flex items-center gap-3 px-3 py-1.5 rounded-lg hover:bg-muted/50 transition-colors"
>
<Avatar className="w-8 h-8 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={pet.name} />}
<AvatarFallback className="text-xs bg-muted">
{pet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{pet.name}</p>
<p className="text-xs text-muted-foreground">
{pet.breed?.name ?? pet.species.name} · {pet._count.followers} followers
</p>
</div>
{activePetId && activePetId !== pet.id && (
<FollowButton
followerPetId={activePetId}
followeePetId={pet.id}
followeeName={pet.name}
/>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</TabsContent>
{/* Tags tab */}
<TabsContent value="tags">
{!tagsEnabled ? (
+9 -4
View File
@@ -110,6 +110,11 @@ export type PostCardProps = {
post: PostCardPost;
/** Called after successful delete so parent can remove from list */
onDeleted?: (postId: string) => void;
/**
* "feed" (default): images cropped to max-h-[560px] object-cover — consistent card heights.
* "detail": images shown in full original format (object-contain, black bg) — used in profile sheet.
*/
variant?: "feed" | "detail";
};
function formatRelativeTime(date: Date | string): string {
@@ -127,7 +132,7 @@ function formatRelativeTime(date: Date | string): string {
return new Date(date).toLocaleDateString();
}
export function PostCard({ post, onDeleted }: PostCardProps) {
export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) {
const trpc = useTRPC();
const { activePetId } = useActivePet();
@@ -325,7 +330,7 @@ export function PostCard({ post, onDeleted }: PostCardProps) {
<img
src={getMediaUrl(img.storageKey) ?? ""}
alt={currentCaption ?? `${pet.name}'s post`}
className="w-full object-cover max-h-[560px]"
className={variant === "detail" ? "w-full object-contain bg-black max-h-[70dvh]" : "w-full object-cover max-h-[560px]"}
/>
</CarouselItem>
))}
@@ -357,12 +362,12 @@ export function PostCard({ post, onDeleted }: PostCardProps) {
</div>
) : (
/* Single image */
<div className="w-full">
<div className={variant === "detail" ? "w-full bg-black flex justify-center" : "w-full"}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getMediaUrl(images[0].storageKey) ?? ""}
alt={currentCaption ?? `${pet.name}'s post`}
className="w-full object-cover max-h-[560px]"
className={variant === "detail" ? "max-w-full max-h-[70dvh] object-contain" : "w-full object-cover max-h-[560px]"}
/>
</div>
)}
+1
View File
@@ -123,6 +123,7 @@ export function ProfileTabs({ petId }: ProfileTabsProps) {
{selectedPost && (
<PostCard
post={selectedPost}
variant="detail"
onDeleted={() => setSelectedPost(null)}
/>
)}
+22 -22
View File
@@ -2,6 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
interface PawRingProps {
petId: string;
@@ -13,39 +14,40 @@ interface PawRingProps {
onPress?: () => void;
}
const RING_PAD = 8; // px of space outside avatar for the paw outline
const RING_PAD = 8;
/**
* PawRing — wraps an avatar with a paw-print outline when the pet has an active story.
*
* Design: the main pad is a circle slightly larger than the avatar; four toe beans sit
* above it, collectively forming a paw silhouette. The entire outline is orange-500.
* Activates only when stories.hasActiveStory returns true (STORY-02).
* Orange = unseen stories; gray = all stories already seen; no ring = no stories (STORY-02).
* viewerPetId is read from ActivePetContext so callers don't need to pass it explicitly.
*/
export function PawRing({ petId, children, size = 32, onPress }: PawRingProps) {
const trpc = useTRPC();
const { data: hasStory = false } = useQuery(
trpc.stories.hasActiveStory.queryOptions({ petId })
const { activePetId } = useActivePet();
const { data = { hasStory: false, allSeen: false } } = useQuery(
trpc.stories.hasActiveStory.queryOptions({
petId,
viewerPetId: activePetId ?? undefined,
})
);
const S = size + RING_PAD * 2; // total container px
const { hasStory, allSeen } = data;
const ringColor = allSeen ? "#9ca3af" : "#f97316"; // gray-400 if seen, orange-500 if unseen
const S = size + RING_PAD * 2;
// All SVG coordinates derived from container size S so the paw scales with the avatar.
// viewBox is fixed at 100×100; S px maps to 100 SVG units.
// Main pad: slightly larger than the avatar circle, centered horizontally,
// shifted down so the toe beans have room above.
const mainCx = 50;
const mainCy = 50;
const mainR = 44;
const strokeW = Math.max(4, (size / 8)); // thicker ring for larger avatars
const strokeW = Math.max(4, size / 8);
// 4 toe beans above the main pad — positions as fractions of the 100-unit viewBox.
// They overlap the top of the main circle slightly (natural paw anatomy).
const beans = [
{ cx: 13, cy: 10, r: 8 }, // far left toe
{ cx: 31, cy: 1, r: 10 }, // inner left toe
{ cx: 69, cy: 1, r: 10 }, // inner right toe
{ cx: 87, cy: 10, r: 8 }, // far right toe
{ cx: 13, cy: 10, r: 8 },
{ cx: 31, cy: 1, r: 10 },
{ cx: 69, cy: 1, r: 10 },
{ cx: 87, cy: 10, r: 8 },
];
const Tag = hasStory && onPress ? "button" : "div";
@@ -66,18 +68,16 @@ export function PawRing({ petId, children, size = 32, onPress }: PawRingProps) {
aria-hidden="true"
style={{ overflow: "visible" }}
>
{/* Main pad ring */}
<circle
cx={mainCx}
cy={mainCy}
r={mainR}
fill="none"
stroke="#f97316"
stroke={ringColor}
strokeWidth={strokeW}
/>
{/* Toe beans */}
{beans.map((b, i) => (
<circle key={i} cx={b.cx} cy={b.cy} r={b.r} fill="#f97316" />
<circle key={i} cx={b.cx} cy={b.cy} r={b.r} fill={ringColor} />
))}
</svg>
)}
+1 -1
View File
@@ -23,7 +23,7 @@ export const notificationsRouter = router({
take: input.limit + 1,
include: {
actorPet: { select: { id: true, name: true, avatarKey: true } },
post: { select: { id: true, images: { take: 1, orderBy: { position: "asc" } } } },
post: { select: { id: true, petId: true, images: { take: 1, orderBy: { position: "asc" } } } },
comment: { select: { id: true, body: true } },
},
});
+48
View File
@@ -1,6 +1,7 @@
import "server-only";
import { z } from "zod";
import { router, protectedProcedure } from "@/trpc/init";
import { clerkClient } from "@clerk/nextjs/server";
export const searchRouter = router({
pets: protectedProcedure
@@ -80,4 +81,51 @@ export const searchRouter = router({
nextCursor: hasNextPage ? posts[posts.length - 1]!.id : null,
};
}),
/**
* Search for owner accounts by Clerk username/name, return their pets.
* Useful when sharing a human's @username instead of individual pet names.
*/
byOwner: protectedProcedure
.input(z.object({ query: z.string().min(1).max(100) }))
.query(async ({ ctx, input }) => {
const client = await clerkClient();
const { data: clerkUsers } = await client.users.getUserList({
query: input.query,
limit: 10,
});
if (clerkUsers.length === 0) return { owners: [] };
const userIds = clerkUsers.map((u) => u.id);
const owners = await ctx.prisma.owner.findMany({
where: { id: { in: userIds } },
include: {
pets: {
include: {
species: true,
breed: true,
_count: { select: { followers: true } },
},
orderBy: { name: "asc" },
},
},
});
return {
owners: owners
.filter((owner) => owner.pets.length > 0)
.map((owner) => {
const cu = clerkUsers.find((u) => u.id === owner.id)!;
return {
id: owner.id,
username: cu.username ?? null,
firstName: cu.firstName ?? null,
lastName: cu.lastName ?? null,
imageUrl: cu.imageUrl ?? null,
pets: owner.pets,
};
}),
};
}),
});
+21 -5
View File
@@ -94,16 +94,32 @@ export const storiesRouter = router({
}),
/**
* Returns true if the pet has at least one non-expired story (STORY-02).
* Used by PawRing to decide whether to show the paw outline.
* Returns story ring state for a pet (STORY-02).
* { hasStory: false } → no ring
* { hasStory: true, allSeen: false } → orange ring (unseen stories)
* { hasStory: true, allSeen: true } → gray ring (all stories seen)
* viewerPetId is optional — without it allSeen is always false.
*/
hasActiveStory: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.input(z.object({ petId: z.string().min(1), viewerPetId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const count = await ctx.prisma.story.count({
const activeStories = await ctx.prisma.story.findMany({
where: { petId: input.petId, expiresAt: { gt: new Date() } },
select: { id: true },
});
return count > 0;
if (activeStories.length === 0) return { hasStory: false, allSeen: false };
if (!input.viewerPetId) return { hasStory: true, allSeen: false };
const viewedCount = await ctx.prisma.storyView.count({
where: {
storyId: { in: activeStories.map((s) => s.id) },
viewerPetId: input.viewerPetId,
},
});
return { hasStory: true, allSeen: viewedCount >= activeStories.length };
}),
/**
+39 -4
View File
@@ -40,12 +40,11 @@ export const videosRouter = router({
// Create Mux direct upload — client PUTs directly to the returned URL
const upload = await mux.video.uploads.create({
cors_origin: process.env.NEXT_PUBLIC_APP_URL ?? "*",
// "*" allows uploads from any origin (dev + prod); restrict to APP_URL in production if needed
cors_origin: "*",
new_asset_settings: {
playback_policy: ["public"],
// max_resolution_tier tells Mux to cap at 1080p (cost control)
max_resolution_tier: "1080p",
passthrough: "", // will be set after post creation
},
});
@@ -76,7 +75,8 @@ export const videosRouter = router({
/**
* Get the video post status — used to poll while video is PROCESSING.
* Returns the VideoPost record so the UI can transition to the player when READY.
* When PROCESSING, checks Mux directly so the status self-heals in dev
* environments where webhooks can't reach localhost.
*/
getByPostId: protectedProcedure
.input(z.object({ postId: z.string().min(1) }))
@@ -85,6 +85,41 @@ export const videosRouter = router({
where: { postId: input.postId },
});
if (!videoPost) throw new TRPCError({ code: "NOT_FOUND" });
if (videoPost.status === "PROCESSING" && process.env.MUX_TOKEN_ID) {
try {
let assetId = videoPost.muxAssetId;
if (!assetId && videoPost.muxUploadId) {
const upload = await mux.video.uploads.retrieve(videoPost.muxUploadId);
if (upload.asset_id) assetId = upload.asset_id;
}
if (assetId) {
const asset = await mux.video.assets.retrieve(assetId);
if (asset.status === "ready" && asset.playback_ids?.[0]) {
return await ctx.prisma.videoPost.update({
where: { postId: input.postId },
data: {
status: "READY",
muxAssetId: assetId,
muxPlaybackId: asset.playback_ids[0].id,
durationSecs: asset.duration ? Math.round(asset.duration) : null,
aspectRatio: asset.aspect_ratio ?? null,
},
});
} else if (asset.status === "errored") {
return await ctx.prisma.videoPost.update({
where: { postId: input.postId },
data: { status: "ERROR", muxAssetId: assetId },
});
}
}
} catch {
// Mux check failed — return current DB state unchanged
}
}
return videoPost;
}),