Files
Epicure/apps/web/components/recipe/can-cook-content.tsx
T
Arnaud e5d1080fb9 feat: implement remaining TODO.md feature ideas + fix mobile headers
Implements the six previously-unscoped feature ideas plus a mobile
layout fix reported via screenshot:

- Mobile: Recipes/Collections/Pantry/Meal Plan/Shopping Lists headers
  now stack and wrap instead of clipping buttons on narrow viewports.
- Recipe diff/compare view: word/list diff against any past version,
  next to Restore in version history.
- Shared meal plans & shopping lists: new shoppingListMembers/
  mealPlanMembers tables (viewer/editor roles, mirrors
  collectionMembers), share dialogs, membership-checked routes.
- PDF cookbook export: /print/collection/[id] renders a whole
  collection with page breaks, using the existing print-CSS pattern
  instead of adding a PDF rendering dependency.
- Grocery delivery handoff: shopping lists can copy-as-text (works
  today) or send to Instacart once INSTACART_API_KEY is configured
  (stub adapter — real API needs a partner agreement).
- Personalized "For You" feed tab: ranks public recipes by tag/
  dietary overlap with the user's favorited/highly-rated history.
- PWA: added manifest.json + icons on top of the existing service
  worker so the app is installable; cook-mode pages were already
  cached for offline use.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 12:13:00 +02:00

148 lines
5.0 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 { useTranslations } from "next-intl";
import { Package, Clock } 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";
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 ? (
<img src={cover.url} alt="" className="h-14 w-14 rounded-lg object-cover shrink-0" />
) : (
<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>
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed rounded-xl gap-4">
<Package className="h-8 w-8 text-muted-foreground" />
<p className="text-muted-foreground text-sm">{t("emptyPantry")}</p>
<Link href="/pantry" className={cn(buttonVariants({ size: "sm" }))}>
{t("addPantryItems")}
</Link>
</div>
</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 && (
<div className="flex flex-col items-center justify-center h-48 border-2 border-dashed rounded-xl gap-3">
<p className="text-muted-foreground text-sm">{t("noMatches")}</p>
<Link href="/pantry" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
{t("addPantryItems")}
</Link>
</div>
)}
{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>
);
}