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:
1 parent
38952b1563
commit
e407064866
3 files changed
+313
-54
No files matched your search
+231
-53
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user