From 8b749f432ec76405803ee6669a153d1937bfe314 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Fri, 10 Jul 2026 16:30:56 +0200 Subject: [PATCH] feat: shared, more pleasing empty-state component across the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every "nothing here" page had its own copy-pasted dashed-border box — icon + one muted line, inconsistent (some had a CTA, some didn't, no description text anywhere). Replaced with one shared EmptyState component: icon in a soft tinted circle, a real heading plus optional description, and primary/secondary actions (either a Link or an arbitrary action slot for things like "New Collection" that open a dialog rather than navigate). Applied to: recipes (no recipes / no search match), favorites, feed (no one followed / no new posts / trending / for-you), collections (index + detail), shopping lists, pantry, notifications, can-cook. Left the small inline "no trending"/"no recent" lines inside Explore's already-labeled sections and the notification-bell dropdown alone — different context, a full empty-state box would be heavier than the space warrants. Co-Authored-By: Claude Sonnet 5 --- apps/web/app/(app)/collections/[id]/page.tsx | 7 +- apps/web/app/(app)/recipes/favorites/page.tsx | 2 +- .../collections/collections-page-content.tsx | 11 ++- .../web/components/feed/feed-page-content.tsx | 14 ++-- .../components/meal-plan/pantry-manager.tsx | 5 +- .../notifications-page-content.tsx | 4 +- .../components/recipe/can-cook-content.tsx | 25 +++--- apps/web/components/recipe/favorites-grid.tsx | 16 ++-- .../components/recipe/recipes-empty-state.tsx | 38 ++++----- apps/web/components/shared/empty-state.tsx | 78 +++++++++++++++++++ .../shopping-lists-page-content.tsx | 11 ++- apps/web/messages/en.json | 11 ++- apps/web/messages/fr.json | 11 ++- 13 files changed, 163 insertions(+), 70 deletions(-) create mode 100644 apps/web/components/shared/empty-state.tsx diff --git a/apps/web/app/(app)/collections/[id]/page.tsx b/apps/web/app/(app)/collections/[id]/page.tsx index 50ed495..662511e 100644 --- a/apps/web/app/(app)/collections/[id]/page.tsx +++ b/apps/web/app/(app)/collections/[id]/page.tsx @@ -2,7 +2,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { headers } from "next/headers"; import Link from "next/link"; -import { Printer } from "lucide-react"; +import { Printer, UtensilsCrossed } from "lucide-react"; import { auth } from "@/lib/auth/server"; import { db, collections, eq, and, or } from "@epicure/db"; import { RecipeCard } from "@/components/recipe/recipe-card"; @@ -11,6 +11,7 @@ import { ShareCollectionButton } from "@/components/collections/share-collection import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { ExportMarkdownButton } from "@/components/shared/export-markdown-button"; +import { EmptyState } from "@/components/shared/empty-state"; import { collectionToMarkdown } from "@/lib/markdown/collection"; import { getMessages } from "@/lib/i18n/server"; @@ -71,9 +72,7 @@ export default async function CollectionPage({ params }: Params) { {col.recipes.length === 0 ? ( -
-

No recipes in this collection yet.

-
+ ) : (
{col.recipes.map(({ recipe }) => ( diff --git a/apps/web/app/(app)/recipes/favorites/page.tsx b/apps/web/app/(app)/recipes/favorites/page.tsx index 21ec2ee..f141d6f 100644 --- a/apps/web/app/(app)/recipes/favorites/page.tsx +++ b/apps/web/app/(app)/recipes/favorites/page.tsx @@ -97,7 +97,7 @@ export default async function FavoriteRecipesPage({

- + {totalPages > 1 && (
diff --git a/apps/web/components/collections/collections-page-content.tsx b/apps/web/components/collections/collections-page-content.tsx index 5658d57..3c9a326 100644 --- a/apps/web/components/collections/collections-page-content.tsx +++ b/apps/web/components/collections/collections-page-content.tsx @@ -5,6 +5,7 @@ import { FolderOpen, Flame } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; import { NewCollectionButton } from "@/components/social/new-collection-button"; +import { EmptyState } from "@/components/shared/empty-state"; import { cn } from "@/lib/utils"; type Collection = { @@ -39,10 +40,12 @@ export function CollectionsPageContent({ collections }: Props) {
{collections.length === 0 ? ( -
- -

{t("empty")}

-
+ } + /> ) : (
{collections.map((col) => ( diff --git a/apps/web/components/feed/feed-page-content.tsx b/apps/web/components/feed/feed-page-content.tsx index 882e62a..803751e 100644 --- a/apps/web/components/feed/feed-page-content.tsx +++ b/apps/web/components/feed/feed-page-content.tsx @@ -2,10 +2,11 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { Clock, Users, ChefHat, Flame, Heart, Sparkles } from "lucide-react"; +import { Clock, Users, ChefHat, Flame, Heart, Sparkles, Rss, UserPlus } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/shared/empty-state"; import { useLocale } from "@/lib/i18n/provider"; const PAGE_SIZE = 20; @@ -139,7 +140,7 @@ function PaginatedFeedTab({ ); } - if (recipes.length === 0) return

{emptyMessage}

; + if (recipes.length === 0) return ; const hasMore = recipes.length < total; @@ -213,9 +214,12 @@ export function FeedPageContent({ followedCount }: Props) { {tab === "following" ? ( followedCount === 0 ? ( -
-

{t("followEmpty")}

-
+ ) : ( ) diff --git a/apps/web/components/meal-plan/pantry-manager.tsx b/apps/web/components/meal-plan/pantry-manager.tsx index 4f150c8..9f76660 100644 --- a/apps/web/components/meal-plan/pantry-manager.tsx +++ b/apps/web/components/meal-plan/pantry-manager.tsx @@ -3,11 +3,12 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { Plus, Trash2, AlertTriangle } from "lucide-react"; +import { Plus, Trash2, AlertTriangle, Package } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { PantryScanDialog } from "@/components/pantry/pantry-scan-dialog"; +import { EmptyState } from "@/components/shared/empty-state"; import { AlertDialog, AlertDialogAction, @@ -98,7 +99,7 @@ export function PantryManager({ initialItems }: { initialItems: PantryItem[] }) {/* Item list */} {items.length === 0 ? ( -

{t("empty")}

+ ) : (
{sorted.map((item) => { diff --git a/apps/web/components/notifications/notifications-page-content.tsx b/apps/web/components/notifications/notifications-page-content.tsx index 1427dbf..590f78f 100644 --- a/apps/web/components/notifications/notifications-page-content.tsx +++ b/apps/web/components/notifications/notifications-page-content.tsx @@ -3,8 +3,10 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { useTranslations } from "next-intl"; +import { Bell } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/shared/empty-state"; import { cn } from "@/lib/utils"; import { notificationHref, type NotificationType } from "@/lib/notification-links"; @@ -114,7 +116,7 @@ export function NotificationsPageContent() {
) : items.length === 0 ? ( -

{t("empty")}

+ ) : (
{items.map((n) => ( diff --git a/apps/web/components/recipe/can-cook-content.tsx b/apps/web/components/recipe/can-cook-content.tsx index df09dbd..ff5bae9 100644 --- a/apps/web/components/recipe/can-cook-content.tsx +++ b/apps/web/components/recipe/can-cook-content.tsx @@ -3,11 +3,10 @@ import Link from "next/link"; import Image from "next/image"; import { useTranslations } from "next-intl"; -import { Package, Clock } from "lucide-react"; +import { Package, Clock, ChefHat } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; -import { buttonVariants } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; +import { EmptyState } from "@/components/shared/empty-state"; type ScoredItem = { recipe: { @@ -82,13 +81,7 @@ export function CanCookContent({ pantryCount, scored }: Props) {

{t("title")}

{t("subtitle")}

-
- -

{t("emptyPantry")}

- - {t("addPantryItems")} - -
+
); } @@ -129,12 +122,12 @@ export function CanCookContent({ pantryCount, scored }: Props) { )} {canCook.length === 0 && almostCook.length === 0 && ( -
-

{t("noMatches")}

- - {t("addPantryItems")} - -
+ )} {rest.length > 0 && ( diff --git a/apps/web/components/recipe/favorites-grid.tsx b/apps/web/components/recipe/favorites-grid.tsx index 287b5a6..6cf0b26 100644 --- a/apps/web/components/recipe/favorites-grid.tsx +++ b/apps/web/components/recipe/favorites-grid.tsx @@ -1,16 +1,21 @@ "use client"; import { useState } from "react"; +import { useTranslations } from "next-intl"; import { Heart } from "lucide-react"; import { FavoriteRecipeCard, type FavoriteRecipe } from "@/components/recipe/favorite-recipe-card"; +import { EmptyState } from "@/components/shared/empty-state"; export function FavoritesGrid({ initialRecipes, - emptyLabel, + emptyTitle, + emptyDescription, }: { initialRecipes: FavoriteRecipe[]; - emptyLabel: string; + emptyTitle: string; + emptyDescription: string; }) { + const t = useTranslations("recipes"); const [recipes, setRecipes] = useState(initialRecipes); function handleUnfavorited(recipeId: string) { @@ -18,12 +23,7 @@ export function FavoritesGrid({ } if (recipes.length === 0) { - return ( -
- -

{emptyLabel}

-
- ); + return ; } return ( diff --git a/apps/web/components/recipe/recipes-empty-state.tsx b/apps/web/components/recipe/recipes-empty-state.tsx index 74f52c4..9d135f9 100644 --- a/apps/web/components/recipe/recipes-empty-state.tsx +++ b/apps/web/components/recipe/recipes-empty-state.tsx @@ -1,9 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; -import Link from "next/link"; -import { PlusCircle } from "lucide-react"; -import { buttonVariants } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; +import { BookOpen, Search } from "lucide-react"; +import { EmptyState } from "@/components/shared/empty-state"; export function RecipesEmptyState({ query, count }: { query: string; count: number }) { const t = useTranslations("recipes"); @@ -14,24 +12,18 @@ export function RecipesEmptyState({ query, count }: { query: string; count: numb

) : null; } - return ( -
- {query ? ( - <> -

{t("noMatch")} “{query}”

- - {t("clearSearch")} - - - ) : ( - <> -

{t("noRecipes")}

- - - {t("createFirst")} - - - )} -
+ return query ? ( + + ) : ( + ); } diff --git a/apps/web/components/shared/empty-state.tsx b/apps/web/components/shared/empty-state.tsx new file mode 100644 index 0000000..a38b681 --- /dev/null +++ b/apps/web/components/shared/empty-state.tsx @@ -0,0 +1,78 @@ +import type { ComponentType, ReactNode } from "react"; +import Link from "next/link"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +type Action = { + label: string; + href: string; +}; + +/** + * Shared "nothing here yet" state — replaces the ad-hoc dashed-border boxes that used + * to be copy-pasted per page (icon + one line of muted text, inconsistent spacing, + * only some had a CTA). One consistent, warmer treatment: icon in a soft tinted + * circle, a real heading instead of just a muted caption, and an optional primary/ + * secondary action. + */ +export function EmptyState({ + icon: Icon, + title, + description, + action, + secondaryAction, + actionSlot, + compact = false, + className, +}: { + icon: ComponentType<{ className?: string }>; + title: string; + description?: string; + action?: Action; + secondaryAction?: Action; + /** Arbitrary action element (e.g. a button that opens a dialog) instead of a plain Link. */ + actionSlot?: ReactNode; + /** Smaller variant for empty states nested inside a section rather than a full page. */ + compact?: boolean; + className?: string; +}) { + return ( +
+
+ +
+
+

{title}

+ {description && ( +

+ {description} +

+ )} +
+ {(action || secondaryAction || actionSlot) && ( +
+ {action && ( + + {action.label} + + )} + {secondaryAction && ( + + {secondaryAction.label} + + )} + {actionSlot} +
+ )} +
+ ); +} diff --git a/apps/web/components/shopping-lists/shopping-lists-page-content.tsx b/apps/web/components/shopping-lists/shopping-lists-page-content.tsx index 718eb24..9e17699 100644 --- a/apps/web/components/shopping-lists/shopping-lists-page-content.tsx +++ b/apps/web/components/shopping-lists/shopping-lists-page-content.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { ShoppingCart } from "lucide-react"; import { NewShoppingListButton } from "@/components/meal-plan/new-shopping-list-button"; import { ShoppingListActionsMenu } from "@/components/shopping-lists/shopping-list-actions-menu"; +import { EmptyState } from "@/components/shared/empty-state"; type ShoppingListItem = { id: string; @@ -42,10 +43,12 @@ export function ShoppingListsPageContent({ lists: initialLists, sharedLists = [] {lists.length === 0 ? ( -
- -

{t("empty")}

-
+ } + /> ) : (
{lists.map((list) => ( diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 46b8608..10e606c 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -571,7 +571,10 @@ "tabFavorites": "Favorites", "favoritesCount": "{count} favorited recipe", "favoritesCountPlural": "{count} favorited recipes", - "favoritesEmpty": "No favorited recipes yet — tap the heart on any recipe to save it here." + "favoritesEmpty": "No favorited recipes yet — tap the heart on any recipe to save it here.", + "favoritesEmptyTitle": "No favorites yet", + "exploreRecipes": "Explore recipes", + "emptyStateDescription": "Create your own, import from a URL, or let AI generate one to get started." }, "recipeNotes": { "title": "Your private notes", @@ -742,6 +745,7 @@ "removeConfirmTitle": "Remove item?", "removeConfirmDescription": "Remove \"{name}\" from your pantry?", "empty": "No pantry items yet.", + "emptyDescription": "Add items by hand, or scan a barcode / photo to fill it in fast.", "expired": "Expired", "expiresInDays": "Expires in {days}d", "expiresOn": "Expires {date}", @@ -775,6 +779,8 @@ "feed": { "title": "Feed", "followEmpty": "Follow chefs to see their recipes here.", + "followEmptyDescription": "Your following feed fills up as soon as you follow a few people.", + "findPeople": "Find people to follow", "noNew": "No new recipes from people you follow.", "following": "Following", "trending": "Trending", @@ -790,6 +796,7 @@ "title": "Shopping Lists", "subtitle": "Plan your grocery runs", "empty": "No shopping lists yet", + "emptyDescription": "Generate one from a meal plan, or start one from scratch.", "listEmpty": "Empty", "generated": "Generated", "shareTitle": "Share shopping list", @@ -850,6 +857,8 @@ "title": "Collections", "subtitle": "Curate your favorite recipes into collections", "empty": "No collections yet", + "emptyDescription": "Group your recipes by theme, occasion, or however makes sense to you.", + "emptyCollection": "No recipes in this collection yet.", "public": "Public", "recipeCount": "{count} recipe", "recipeCountPlural": "{count} recipes", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 9f768d3..117ae42 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -562,7 +562,10 @@ "tabFavorites": "Favoris", "favoritesCount": "{count} recette favorite", "favoritesCountPlural": "{count} recettes favorites", - "favoritesEmpty": "Aucune recette favorite pour l'instant — touchez le cœur sur une recette pour l'enregistrer ici." + "favoritesEmpty": "Aucune recette favorite pour l'instant — touchez le cœur sur une recette pour l'enregistrer ici.", + "favoritesEmptyTitle": "Aucun favori pour l'instant", + "exploreRecipes": "Explorer les recettes", + "emptyStateDescription": "Créez la vôtre, importez-la depuis une URL, ou laissez l'IA en générer une pour commencer." }, "recipeNotes": { "title": "Vos notes personnelles", @@ -730,6 +733,7 @@ "removeConfirmTitle": "Retirer cet article ?", "removeConfirmDescription": "Retirer « {name} » du garde-manger ?", "empty": "Aucun article dans le garde-manger.", + "emptyDescription": "Ajoutez des articles manuellement, ou scannez un code-barres / une photo pour aller plus vite.", "expired": "Expiré", "expiresInDays": "Expire dans {days}j", "expiresOn": "Expire le {date}", @@ -763,6 +767,8 @@ "feed": { "title": "Fil d'actualité", "followEmpty": "Suivez des chefs pour voir leurs recettes ici.", + "followEmptyDescription": "Votre fil d'abonnements se remplit dès que vous suivez quelques personnes.", + "findPeople": "Trouver des personnes à suivre", "noNew": "Aucune nouvelle recette de vos abonnements.", "following": "Abonnements", "trending": "Tendances", @@ -778,6 +784,7 @@ "title": "Listes de courses", "subtitle": "Planifiez vos courses", "empty": "Aucune liste de courses", + "emptyDescription": "Générez-en une depuis un planning repas, ou créez-en une de zéro.", "listEmpty": "Vide", "generated": "Générée", "shareTitle": "Partager la liste de courses", @@ -838,6 +845,8 @@ "title": "Collections", "subtitle": "Organisez vos recettes favorites en collections", "empty": "Aucune collection pour l'instant", + "emptyDescription": "Regroupez vos recettes par thème, occasion, ou comme bon vous semble.", + "emptyCollection": "Aucune recette dans cette collection pour l'instant.", "public": "Publique", "recipeCount": "{count} recette", "recipeCountPlural": "{count} recettes",