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.
|
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
|
## 0.20.0 — 2026-07-13 22:26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
count,
|
count,
|
||||||
} from "@epicure/db";
|
} from "@epicure/db";
|
||||||
import { ExplorePageContent } from "@/components/search/explore-page-content";
|
import { ExplorePageContent } from "@/components/search/explore-page-content";
|
||||||
|
import { getAvgRatingsByRecipeId } from "@/lib/recipe-ratings";
|
||||||
|
|
||||||
export const metadata: Metadata = {};
|
export const metadata: Metadata = {};
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ export type RecipeResult = {
|
|||||||
difficulty: string | null;
|
difficulty: string | null;
|
||||||
prepMins: number | null;
|
prepMins: number | null;
|
||||||
cookMins: number | null;
|
cookMins: number | null;
|
||||||
|
avgRating: number | null;
|
||||||
|
ratingCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function ExplorePage({
|
export default async function ExplorePage({
|
||||||
@@ -73,8 +76,17 @@ export default async function ExplorePage({
|
|||||||
.orderBy(desc(recipes.createdAt))
|
.orderBy(desc(recipes.createdAt))
|
||||||
.limit(12);
|
.limit(12);
|
||||||
|
|
||||||
const trending: RecipeResult[] = trendingRows.map(({ favoriteCount: _fc, ...r }) => r);
|
const ratingByRecipe = await getAvgRatingsByRecipeId([
|
||||||
const recent: RecipeResult[] = recentRows;
|
...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
|
// 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.
|
// search bar so browsing by tag doesn't require typing the exact word.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
sql,
|
sql,
|
||||||
desc,
|
desc,
|
||||||
} from "@epicure/db";
|
} from "@epicure/db";
|
||||||
|
import { getAvgRatingsByRecipeId } from "@/lib/recipe-ratings";
|
||||||
|
|
||||||
const VALID_DIETARY = ["vegan", "vegetarian", "glutenFree", "dairyFree"] as const;
|
const VALID_DIETARY = ["vegan", "vegetarian", "glutenFree", "dairyFree"] as const;
|
||||||
type DietaryTag = (typeof VALID_DIETARY)[number];
|
type DietaryTag = (typeof VALID_DIETARY)[number];
|
||||||
@@ -143,5 +144,12 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const total = countResult[0]?.total ?? 0;
|
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 Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Clock, ChefHat } from "lucide-react";
|
import { Clock, ChefHat, Star } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const DIFFICULTY_COLORS: Record<string, string> = {
|
const DIFFICULTY_COLORS: Record<string, string> = {
|
||||||
@@ -20,6 +20,8 @@ export type SearchResultRecipe = {
|
|||||||
prepMins: number | null;
|
prepMins: number | null;
|
||||||
cookMins: number | null;
|
cookMins: number | null;
|
||||||
authorName: string | null;
|
authorName: string | null;
|
||||||
|
avgRating?: number | null;
|
||||||
|
ratingCount?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +60,13 @@ export function SearchResultCard({ recipe, className }: { recipe: SearchResultRe
|
|||||||
{recipe.description && (
|
{recipe.description && (
|
||||||
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">{recipe.description}</p>
|
<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">
|
<div className="mt-3 flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
{totalMins > 0 && (
|
{totalMins > 0 && (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ type SearchRecipeResult = {
|
|||||||
prepMins: number | null;
|
prepMins: number | null;
|
||||||
cookMins: number | null;
|
cookMins: number | null;
|
||||||
authorName: string | null;
|
authorName: string | null;
|
||||||
|
avgRating: number | null;
|
||||||
|
ratingCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SearchResponse = {
|
type SearchResponse = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
// 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 = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.20.0",
|
||||||
date: "2026-07-13 22:26",
|
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",
|
"name": "@epicure/web",
|
||||||
"version": "0.20.0",
|
"version": "0.21.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.20.0",
|
"version": "0.21.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user