feat: rating stars on recipe cards (Search, Explore)

New lib/recipe-ratings.ts helper batches avg score + review count for
a set of recipe ids in one grouped query, kept separate from the main
paginated/sorted search query rather than joining+grouping it directly
(would've forced grouping by every selected column). Wired into
/api/v1/search and the Explore page's trending/recent queries;
SearchResultCard renders the star only when a recipe has at least one
rating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-13 22:43:58 +02:00
parent 95a4dccce8
commit c21157fc93
9 changed files with 70 additions and 7 deletions
+5
View File
@@ -2,6 +2,11 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.21.0 — 2026-07-13 22:34
### Added
- **Rating stars on recipe cards** — average rating and review count now show on Search and Explore results, not just the recipe detail page.
## 0.20.0 — 2026-07-13 22:26
### Added
+14 -2
View File
@@ -12,6 +12,7 @@ import {
count,
} from "@epicure/db";
import { ExplorePageContent } from "@/components/search/explore-page-content";
import { getAvgRatingsByRecipeId } from "@/lib/recipe-ratings";
export const metadata: Metadata = {};
@@ -22,6 +23,8 @@ export type RecipeResult = {
difficulty: string | null;
prepMins: number | null;
cookMins: number | null;
avgRating: number | null;
ratingCount: number;
};
export default async function ExplorePage({
@@ -73,8 +76,17 @@ export default async function ExplorePage({
.orderBy(desc(recipes.createdAt))
.limit(12);
const trending: RecipeResult[] = trendingRows.map(({ favoriteCount: _fc, ...r }) => r);
const recent: RecipeResult[] = recentRows;
const ratingByRecipe = await getAvgRatingsByRecipeId([
...trendingRows.map((r) => r.id),
...recentRows.map((r) => r.id),
]);
const withRating = (r: { id: string }) => ({
avgRating: ratingByRecipe.get(r.id)?.avgRating ?? null,
ratingCount: ratingByRecipe.get(r.id)?.ratingCount ?? 0,
});
const trending: RecipeResult[] = trendingRows.map(({ favoriteCount: _fc, ...r }) => ({ ...r, ...withRating(r) }));
const recent: RecipeResult[] = recentRows.map((r) => ({ ...r, ...withRating(r) }));
// Most-used tags across public recipes — shown as filter chips on the
// search bar so browsing by tag doesn't require typing the exact word.
+9 -1
View File
@@ -11,6 +11,7 @@ import {
sql,
desc,
} from "@epicure/db";
import { getAvgRatingsByRecipeId } from "@/lib/recipe-ratings";
const VALID_DIETARY = ["vegan", "vegetarian", "glutenFree", "dairyFree"] as const;
type DietaryTag = (typeof VALID_DIETARY)[number];
@@ -143,5 +144,12 @@ export async function GET(req: NextRequest) {
const total = countResult[0]?.total ?? 0;
return NextResponse.json({ data: rows, total, limit, offset });
const ratingByRecipe = await getAvgRatingsByRecipeId(rows.map((r) => r.id));
const data = rows.map((r) => ({
...r,
avgRating: ratingByRecipe.get(r.id)?.avgRating ?? null,
ratingCount: ratingByRecipe.get(r.id)?.ratingCount ?? 0,
}));
return NextResponse.json({ data, total, limit, offset });
}
@@ -3,7 +3,7 @@
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Badge } from "@/components/ui/badge";
import { Clock, ChefHat } from "lucide-react";
import { Clock, ChefHat, Star } from "lucide-react";
import { cn } from "@/lib/utils";
const DIFFICULTY_COLORS: Record<string, string> = {
@@ -20,6 +20,8 @@ export type SearchResultRecipe = {
prepMins: number | null;
cookMins: number | null;
authorName: string | null;
avgRating?: number | null;
ratingCount?: number;
};
/**
@@ -58,6 +60,13 @@ export function SearchResultCard({ recipe, className }: { recipe: SearchResultRe
{recipe.description && (
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">{recipe.description}</p>
)}
{!!recipe.ratingCount && recipe.avgRating != null && (
<div className="mt-1.5 flex items-center gap-1 text-xs text-muted-foreground">
<Star className="h-3.5 w-3.5 fill-yellow-400 text-yellow-400" />
<span className="font-medium text-foreground">{recipe.avgRating.toFixed(1)}</span>
<span>({recipe.ratingCount})</span>
</div>
)}
<div className="mt-3 flex items-center gap-3 text-xs text-muted-foreground">
{totalMins > 0 && (
<span className="flex items-center gap-1">
@@ -28,6 +28,8 @@ type SearchRecipeResult = {
prepMins: number | null;
cookMins: number | null;
authorName: string | null;
avgRating: number | null;
ratingCount: number;
};
type SearchResponse = {
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.20.0";
export const APP_VERSION = "0.21.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.21.0",
date: "2026-07-13 22:34",
added: [
"**Rating stars on recipe cards** — average rating and review count now show on Search and Explore results, not just the recipe detail page.",
],
},
{
version: "0.20.0",
date: "2026-07-13 22:26",
+20
View File
@@ -0,0 +1,20 @@
import { db, ratings, inArray, sql } from "@epicure/db";
export type RatingSummary = { avgRating: number; ratingCount: number };
/** Average score + review count per recipe, for a batch of recipe ids — used to show rating stars on list/search cards without a per-row join. */
export async function getAvgRatingsByRecipeId(recipeIds: string[]): Promise<Map<string, RatingSummary>> {
if (recipeIds.length === 0) return new Map();
const rows = await db
.select({
recipeId: ratings.recipeId,
avgScore: sql<number>`avg(${ratings.score})::float`,
count: sql<number>`count(*)::int`,
})
.from(ratings)
.where(inArray(ratings.recipeId, recipeIds))
.groupBy(ratings.recipeId);
return new Map(rows.map((r) => [r.recipeId, { avgRating: r.avgScore, ratingCount: r.count }]));
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.20.0",
"version": "0.21.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.20.0",
"version": "0.21.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",