feat(explore): add trending tab with hot posts and hashtags

- explore.getTrending: top-12 photo posts by reaction count (48h window)
  and top-10 hashtags by post count (7d window)
- Explore page opens on Trending tab by default; species/breed tabs
  preserved with breed filter hidden when not applicable
- Trending UI: hashtag chips linking to /hashtag/[tag] + 3-col post
  grid with paw-count overlay and PostCard detail dialog

feat(follows): auto-trigger follower milestone posts

- After each follow, count followers for followeePetId and check against
  all thresholds (500 / 1K / 2.5K / 5K / 10K / 25K / 50K / 100K /
  250K / 500K / 1M)
- Guard against duplicate awards via existing Milestone lookup
- Fire-and-forget: milestone post created + fanned out to follower feeds
  without blocking the follow response
This commit is contained in:
admin committed 2026-06-21 14:41:20 +02:00
1 parent 38952b1563
commit e407064866
3 files changed
+313 -54

No files matched your search

+231 -53
View File
@@ -1,12 +1,18 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { Loader2, TrendingUp } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { ExploreCard } from "@/components/explore/ExploreCard";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { PostCard } from "@/components/feed/PostCard";
import { getMediaUrl } from "@/lib/media-url";
import type { PostCardPost } from "@/components/feed/PostCard";
import {
Select,
SelectContent,
@@ -20,8 +26,13 @@ export default function ExplorePage() {
const trpc = useTRPC();
const { activePetId } = useActivePet();
const [activeSpeciesId, setActiveSpeciesId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string>("trending");
const [activeBreedId, setActiveBreedId] = useState<string | null>(null);
const [selectedPost, setSelectedPost] = useState<PostCardPost | null>(null);
const isTrending = activeTab === "trending";
const activeSpeciesId =
activeTab === "trending" || activeTab === "all" ? null : activeTab;
const { data: speciesList, isLoading: loadingSpecies } = useQuery(
trpc.pets.listSpecies.queryOptions()
@@ -35,12 +46,16 @@ export default function ExplorePage() {
);
const breeds = breedData ?? [];
const { data: trendingData, isLoading: loadingTrending } = useQuery(
trpc.explore.getTrending.queryOptions(undefined, { enabled: isTrending })
);
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
isLoading: loadingPets,
} = useInfiniteQuery(
trpc.explore.listPets.infiniteQueryOptions(
{
@@ -51,15 +66,16 @@ export default function ExplorePage() {
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialCursor: undefined,
enabled: !isTrending,
}
)
);
const pets = data?.pages.flatMap((p) => p.pets) ?? [];
function handleSpeciesChange(speciesId: string | null) {
setActiveSpeciesId(speciesId);
setActiveBreedId(null); // reset breed when switching species
function handleTabChange(tab: string) {
setActiveTab(tab);
setActiveBreedId(null);
}
if (loadingSpecies) {
@@ -74,13 +90,12 @@ export default function ExplorePage() {
<div className="max-w-[640px] mx-auto px-4 py-6">
<h1 className="text-xl font-semibold mb-4">Explore</h1>
{/* Species tabs */}
<Tabs
value={activeSpeciesId ?? "all"}
onValueChange={(v) => handleSpeciesChange(v === "all" ? null : v)}
className="mb-4"
>
<Tabs value={activeTab} onValueChange={handleTabChange} className="mb-4">
<TabsList>
<TabsTrigger value="trending" className="gap-1">
<TrendingUp className="h-3.5 w-3.5" />
Trending
</TabsTrigger>
<TabsTrigger value="all">All</TabsTrigger>
{(speciesList ?? []).map((s) => (
<TabsTrigger key={s.id} value={s.id}>
@@ -90,7 +105,7 @@ export default function ExplorePage() {
</TabsList>
</Tabs>
{/* Breed filter — only visible when a species is selected */}
{/* Breed filter — only when a species tab is active */}
{activeSpeciesId && breeds.length > 0 && (
<div className="mb-4">
<Select
@@ -112,49 +127,212 @@ export default function ExplorePage() {
</div>
)}
{/* Pet grid */}
{isLoading ? (
<div className="flex justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : pets.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No{" "}
{activeBreedId
? breeds.find((b) => b.id === activeBreedId)?.name ?? "breed"
: activeSpeciesId
? (speciesList ?? []).find((s) => s.id === activeSpeciesId)?.name ?? "species"
: ""}
{" "}pets yet
</p>
) : (
<>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4">
{pets.map((pet) => (
<ExploreCard
key={pet.id}
pet={pet as Parameters<typeof ExploreCard>[0]["pet"]}
activePetId={activePetId ?? ""}
/>
{/* Trending tab content */}
{isTrending && (
<TrendingContent
data={trendingData}
isLoading={loadingTrending}
onSelectPost={setSelectedPost}
/>
)}
{/* Pet grid (all / species / breed tabs) */}
{!isTrending &&
(loadingPets ? (
<div className="flex justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : pets.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No{" "}
{activeBreedId
? (breeds.find((b) => b.id === activeBreedId)?.name ?? "breed")
: activeSpeciesId
? ((speciesList ?? []).find((s) => s.id === activeSpeciesId)
?.name ?? "species")
: ""}{" "}
pets yet
</p>
) : (
<>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4">
{pets.map((pet) => (
<ExploreCard
key={pet.id}
pet={pet as Parameters<typeof ExploreCard>[0]["pet"]}
activePetId={activePetId ?? ""}
/>
))}
</div>
{hasNextPage && (
<div className="flex justify-center mt-6">
<Button
variant="outline"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
className="bg-muted"
>
{isFetchingNextPage ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Load more
</Button>
</div>
)}
</>
))}
{/* Post detail dialog */}
<Dialog
open={!!selectedPost}
onOpenChange={(open) => {
if (!open) setSelectedPost(null);
}}
>
<DialogContent
showCloseButton={false}
className="max-w-[560px] w-full p-0 overflow-y-auto max-h-[90dvh] rounded-xl"
>
{selectedPost && (
<PostCard
post={selectedPost}
variant="detail"
onDeleted={() => setSelectedPost(null)}
/>
)}
</DialogContent>
</Dialog>
</div>
);
}
// ─── Trending section ─────────────────────────────────────────────────────────
type TrendingPost = {
id: string;
type: string;
caption: string | null;
createdAt: Date | string;
pet: { id: string; name: string; avatarKey: string | null };
images: { storageKey: string; position: number }[];
milestone: { milestoneType: string; notes: string | null } | null;
_count: { reactions: number; comments: number };
};
type TrendingHashtag = {
id: string;
tag: string;
_count: { posts: number };
};
type TrendingData = {
posts: TrendingPost[];
hashtags: TrendingHashtag[];
};
function TrendingContent({
data,
isLoading,
onSelectPost,
}: {
data: TrendingData | undefined;
isLoading: boolean;
onSelectPost: (post: PostCardPost) => void;
}) {
if (isLoading) {
return (
<div className="flex justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
const posts = data?.posts ?? [];
const hashtags = data?.hashtags ?? [];
const hasContent = posts.length > 0 || hashtags.length > 0;
if (!hasContent) {
return (
<p className="text-sm text-muted-foreground text-center py-10">
No trending content yet — check back soon
</p>
);
}
return (
<div className="space-y-6">
{/* Trending hashtags */}
{hashtags.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Trending hashtags
</h2>
<div className="flex flex-wrap gap-2">
{hashtags.map((ht) => (
<Link key={ht.id} href={`/hashtag/${ht.tag}`}>
<Badge
variant="secondary"
className="cursor-pointer hover:bg-orange-100 hover:text-orange-700 transition-colors text-sm px-3 py-1"
>
#{ht.tag}
<span className="ml-1.5 text-xs text-muted-foreground">
{ht._count.posts}
</span>
</Badge>
</Link>
))}
</div>
</section>
)}
{hasNextPage && (
<div className="flex justify-center mt-6">
<Button
variant="outline"
onClick={() => void fetchNextPage()}
disabled={isFetchingNextPage}
className="bg-muted"
>
{isFetchingNextPage ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Load more
</Button>
</div>
)}
</>
{/* Trending posts grid */}
{posts.length > 0 && (
<section>
<h2 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Hot posts · last 48 h
</h2>
<div className="grid grid-cols-3 gap-0.5">
{posts.map((post) => {
const firstImage = post.images[0];
const mediaUrl = firstImage
? getMediaUrl(firstImage.storageKey)
: null;
const isCarousel = post.images.length > 1;
return (
<button
key={post.id}
type="button"
onClick={() => onSelectPost(post as unknown as PostCardPost)}
className="relative aspect-square overflow-hidden bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-500"
aria-label={post.caption ?? "View trending post"}
>
{mediaUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={mediaUrl}
alt={post.caption ?? "Trending post"}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full bg-muted" />
)}
<div className="absolute bottom-1 left-1 flex items-center gap-0.5 bg-black/50 rounded px-1 py-0.5">
<span className="text-white text-[10px]">🐾</span>
<span className="text-white text-[10px] font-medium">
{post._count.reactions}
</span>
</div>
{isCarousel && (
<div className="absolute top-1 right-1 w-4 h-4 bg-black/50 rounded-sm flex items-center justify-center">
<span className="text-white text-[8px]">+</span>
</div>
)}
</button>
);
})}
</div>
</section>
)}
</div>
);
+37
View File
@@ -2,6 +2,9 @@ import "server-only";
import { z } from "zod";
import { router, publicProcedure } from "@/trpc/init";
const TRENDING_POSTS_WINDOW_MS = 48 * 60 * 60 * 1000; // 48 h
const TRENDING_HASHTAGS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export const exploreRouter = router({
listPets: publicProcedure
.input(
@@ -35,4 +38,38 @@ export const exploreRouter = router({
nextCursor: hasNextPage ? items[items.length - 1]!.id : null,
};
}),
/**
* getTrending — trending posts (48 h window, ranked by reaction count)
* and trending hashtags (7 day window, ranked by post count).
*/
getTrending: publicProcedure.query(async ({ ctx }) => {
const now = Date.now();
const since48h = new Date(now - TRENDING_POSTS_WINDOW_MS);
const since7d = new Date(now - TRENDING_HASHTAGS_WINDOW_MS);
const [posts, hashtags] = await Promise.all([
ctx.prisma.post.findMany({
where: { createdAt: { gte: since48h }, type: "PHOTO" },
include: {
images: { orderBy: { position: "asc" } },
pet: { select: { id: true, name: true, avatarKey: true } },
milestone: true,
_count: { select: { reactions: true, comments: true } },
},
orderBy: { reactions: { _count: "desc" } },
take: 12,
}),
ctx.prisma.hashtag.findMany({
where: {
posts: { some: { post: { createdAt: { gte: since7d } } } },
},
include: { _count: { select: { posts: true } } },
orderBy: { posts: { _count: "desc" } },
take: 10,
}),
]);
return { posts, hashtags };
}),
});
+45 -1
View File
@@ -1,10 +1,25 @@
import "server-only";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { MilestoneType } from "@prisma/client";
import { router, protectedProcedure } from "@/trpc/init";
import type { PrismaClient } from "@prisma/client";
import { redis } from "@/lib/redis";
import { backfillOnFollow } from "@/lib/feed-helpers";
import { backfillOnFollow, fanOutPost } from "@/lib/feed-helpers";
const FOLLOWER_MILESTONES: Partial<Record<number, MilestoneType>> = {
500: MilestoneType.FOLLOWERS_500,
1000: MilestoneType.FOLLOWERS_1K,
2500: MilestoneType.FOLLOWERS_2_5K,
5000: MilestoneType.FOLLOWERS_5K,
10000: MilestoneType.FOLLOWERS_10K,
25000: MilestoneType.FOLLOWERS_25K,
50000: MilestoneType.FOLLOWERS_50K,
100000: MilestoneType.FOLLOWERS_100K,
250000: MilestoneType.FOLLOWERS_250K,
500000: MilestoneType.FOLLOWERS_500K,
1000000: MilestoneType.FOLLOWERS_1M,
};
/**
* Ownership assertion helper — verifies that a pet belongs to the requesting
@@ -76,6 +91,35 @@ export const followsRouter = router({
// Backfill followee's recent posts into follower's Redis feed inbox (SOCL-01)
await backfillOnFollow(redis, followerPetId, followeePetId, ctx.prisma).catch(() => {});
// MILE-F: check follower milestone thresholds — fire-and-forget
void (async () => {
const count = await ctx.prisma.follow.count({ where: { followeePetId } });
const milestoneType = FOLLOWER_MILESTONES[count];
if (!milestoneType) return;
// Guard: don't award the same milestone twice
const existing = await ctx.prisma.milestone.findFirst({
where: { milestoneType, post: { petId: followeePetId } },
});
if (existing) return;
const post = await ctx.prisma.post.create({
data: {
petId: followeePetId,
type: "MILESTONE",
caption: null,
milestone: { create: { milestoneType, notes: null } },
},
include: {
images: { orderBy: { position: "asc" } },
milestone: true,
pet: { select: { id: true, name: true, avatarKey: true } },
},
});
await fanOutPost(redis, post.id, followeePetId, post.createdAt, ctx.prisma);
})().catch(() => {});
// NOTF-01: notify the followee (fire-and-forget — never fails the follow)
ctx.prisma.notification
.create({