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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.20.0",
|
||||
"version": "0.21.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
Reference in New Issue
Block a user