diff --git a/src/app/(app)/notifications/page.tsx b/src/app/(app)/notifications/page.tsx index 00c4f1b..e7bb33f 100644 --- a/src/app/(app)/notifications/page.tsx +++ b/src/app/(app)/notifications/page.tsx @@ -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 (

@@ -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 (
  • 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 */}
    @@ -158,6 +156,7 @@ export default function NotificationsPage() { e.stopPropagation()} > {notif.actorPet.name} diff --git a/src/app/(app)/search/page.tsx b/src/app/(app)/search/page.tsx index a11c67c..d2fa968 100644 --- a/src/app/(app)/search/page.tsx +++ b/src/app/(app)/search/page.tsx @@ -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() {

    Search

    - {/* Search input */}
    ) : ( /* Single image */ -
    +
    {/* eslint-disable-next-line @next/next/no-img-element */} {currentCaption
    )} diff --git a/src/components/profile/ProfileTabs.tsx b/src/components/profile/ProfileTabs.tsx index 74a350b..5212a90 100644 --- a/src/components/profile/ProfileTabs.tsx +++ b/src/components/profile/ProfileTabs.tsx @@ -123,6 +123,7 @@ export function ProfileTabs({ petId }: ProfileTabsProps) { {selectedPost && ( setSelectedPost(null)} /> )} diff --git a/src/components/stories/PawRing.tsx b/src/components/stories/PawRing.tsx index c6de01a..59560a1 100644 --- a/src/components/stories/PawRing.tsx +++ b/src/components/stories/PawRing.tsx @@ -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 */} - {/* Toe beans */} {beans.map((b, i) => ( - + ))} )} diff --git a/src/trpc/routers/notifications.ts b/src/trpc/routers/notifications.ts index 8282e6b..826ed37 100644 --- a/src/trpc/routers/notifications.ts +++ b/src/trpc/routers/notifications.ts @@ -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 } }, }, }); diff --git a/src/trpc/routers/search.ts b/src/trpc/routers/search.ts index 28c400b..d46b68c 100644 --- a/src/trpc/routers/search.ts +++ b/src/trpc/routers/search.ts @@ -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, + }; + }), + }; + }), }); diff --git a/src/trpc/routers/stories.ts b/src/trpc/routers/stories.ts index 49574b6..f068db4 100644 --- a/src/trpc/routers/stories.ts +++ b/src/trpc/routers/stories.ts @@ -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 }; }), /** diff --git a/src/trpc/routers/videos.ts b/src/trpc/routers/videos.ts index bf3118e..8ec6ebf 100644 --- a/src/trpc/routers/videos.ts +++ b/src/trpc/routers/videos.ts @@ -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; }),