diff --git a/CHANGELOG.md b/CHANGELOG.md index b6b7904..3ec0e94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/web/app/(app)/explore/page.tsx b/apps/web/app/(app)/explore/page.tsx index 1857298..a1fbb11 100644 --- a/apps/web/app/(app)/explore/page.tsx +++ b/apps/web/app/(app)/explore/page.tsx @@ -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. diff --git a/apps/web/app/api/v1/search/route.ts b/apps/web/app/api/v1/search/route.ts index 6c7489d..0f97465 100644 --- a/apps/web/app/api/v1/search/route.ts +++ b/apps/web/app/api/v1/search/route.ts @@ -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 }); } diff --git a/apps/web/components/recipe/search-result-card.tsx b/apps/web/components/recipe/search-result-card.tsx index dec9421..e125446 100644 --- a/apps/web/components/recipe/search-result-card.tsx +++ b/apps/web/components/recipe/search-result-card.tsx @@ -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 = { @@ -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 && (

{recipe.description}

)} + {!!recipe.ratingCount && recipe.avgRating != null && ( +
+ + {recipe.avgRating.toFixed(1)} + ({recipe.ratingCount}) +
+ )}
{totalMins > 0 && ( diff --git a/apps/web/components/search/explore-page-content.tsx b/apps/web/components/search/explore-page-content.tsx index 35da3f4..5d461a0 100644 --- a/apps/web/components/search/explore-page-content.tsx +++ b/apps/web/components/search/explore-page-content.tsx @@ -28,6 +28,8 @@ type SearchRecipeResult = { prepMins: number | null; cookMins: number | null; authorName: string | null; + avgRating: number | null; + ratingCount: number; }; type SearchResponse = { diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 8652fc9..b87069b 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -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", diff --git a/apps/web/lib/recipe-ratings.ts b/apps/web/lib/recipe-ratings.ts new file mode 100644 index 0000000..81daa9c --- /dev/null +++ b/apps/web/lib/recipe-ratings.ts @@ -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> { + if (recipeIds.length === 0) return new Map(); + + const rows = await db + .select({ + recipeId: ratings.recipeId, + avgScore: sql`avg(${ratings.score})::float`, + count: sql`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 }])); +} diff --git a/apps/web/package.json b/apps/web/package.json index 4c34a3f..e49d72c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.20.0", + "version": "0.21.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 56e7584..0cdaaf9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.20.0", + "version": "0.21.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",