Files
Epicure/apps/web/components/recipe/can-cook-content.tsx
T
Arnaud 8b749f432e feat: shared, more pleasing empty-state component across the app
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 <noreply@anthropic.com>
2026-07-10 16:30:56 +02:00

144 lines
4.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Package, Clock, ChefHat } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { EmptyState } from "@/components/shared/empty-state";
type ScoredItem = {
recipe: {
id: string;
title: string;
description: string | null;
photos: Array<{ url: string }>;
};
matched: number;
total: number;
pct: number;
missing: string[];
usesExpiring: string[];
};
type Props = {
pantryCount: number;
scored: ScoredItem[];
};
function RecipeRow({ s }: { s: ScoredItem }) {
const t = useTranslations("canCook");
const cover = s.recipe.photos?.[0];
return (
<Link
href={`/recipes/${s.recipe.id}`}
className="flex items-center gap-4 rounded-xl border p-3 hover:bg-accent transition-colors"
>
{cover ? (
<div className="relative h-14 w-14 rounded-lg overflow-hidden shrink-0">
<Image src={cover.url} alt={s.recipe.title} fill className="object-cover" />
</div>
) : (
<div className="h-14 w-14 rounded-lg bg-muted shrink-0" />
)}
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium truncate">{s.recipe.title}</p>
{s.usesExpiring.length > 0 && (
<Badge variant="outline" className="shrink-0 text-orange-500 border-orange-500 gap-1">
<Clock className="h-3 w-3" />
{t("useItUp")}
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Progress value={s.pct} className="h-1.5 flex-1 max-w-[120px]" />
<span className="text-xs text-muted-foreground">
{t("ingredientProgress", { matched: s.matched, total: s.total })}
</span>
</div>
{s.missing.length > 0 && (
<p className="text-xs text-muted-foreground truncate">
{t("missing")}: {s.missing.join(", ")}{s.missing.length < s.total - s.matched ? "…" : ""}
</p>
)}
</div>
<Badge variant={s.pct === 100 ? "default" : "secondary"} className="shrink-0">
{s.pct}%
</Badge>
</Link>
);
}
export function CanCookContent({ pantryCount, scored }: Props) {
const t = useTranslations("canCook");
if (pantryCount === 0) {
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
</div>
<EmptyState icon={Package} title={t("emptyPantry")} action={{ label: t("addPantryItems"), href: "/pantry" }} />
</div>
);
}
const canCook = scored.filter((s) => s.pct === 100);
const almostCook = scored.filter((s) => s.pct >= 70 && s.pct < 100);
const rest = scored.filter((s) => s.pct < 70 && s.pct > 0);
return (
<div className="space-y-8 max-w-2xl">
<div>
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground mt-1">{t("subtitle", { count: pantryCount })}</p>
</div>
{canCook.length > 0 && (
<section className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
{t("readyToCook")}
<Badge>{canCook.length}</Badge>
</h2>
<div className="space-y-2">
{canCook.map((s) => <RecipeRow key={s.recipe.id} s={s} />)}
</div>
</section>
)}
{almostCook.length > 0 && (
<section className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
{t("almostThere")} <span className="text-sm font-normal text-muted-foreground">(7099%)</span>
<Badge variant="secondary">{almostCook.length}</Badge>
</h2>
<div className="space-y-2">
{almostCook.map((s) => <RecipeRow key={s.recipe.id} s={s} />)}
</div>
</section>
)}
{canCook.length === 0 && almostCook.length === 0 && (
<EmptyState
icon={ChefHat}
title={t("noMatches")}
secondaryAction={{ label: t("addPantryItems"), href: "/pantry" }}
compact
/>
)}
{rest.length > 0 && (
<section className="space-y-3">
<h2 className="text-sm font-medium text-muted-foreground">{t("partialMatches")}</h2>
<div className="space-y-2">
{rest.slice(0, 5).map((s) => <RecipeRow key={s.recipe.id} s={s} />)}
</div>
</section>
)}
</div>
);
}