Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
"newProducts": "Neue Produkte",
"newInCategory": "Neu in {category}",
"viewAll": "Alle anzeigen →",
"viewHistory": "Verlauf anzeigen →",
"browse": "Durchsuchen →",
"scanned": "Gescannt",
"viewed": "Angesehen",
Expand Down
1 change: 1 addition & 0 deletions frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
"newProducts": "New Products",
"newInCategory": "New {category}",
"viewAll": "View all →",
"viewHistory": "View history →",
"browse": "Browse →",
"scanned": "Scanned",
"viewed": "Viewed",
Expand Down
1 change: 1 addition & 0 deletions frontend/messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
"newProducts": "Nowe produkty",
"newInCategory": "Nowe {category}",
"viewAll": "Pokaż wszystko →",
"viewHistory": "Historia →",
"browse": "Przeglądaj →",
"scanned": "Zeskanowane",
"viewed": "Przeglądane",
Expand Down
79 changes: 77 additions & 2 deletions frontend/src/components/dashboard/RecentlyViewed.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,18 @@
const map: Record<string, string> = {
"dashboard.recentlyViewedCompact": "Recently Viewed",
"dashboard.viewAll": "View all",
"dashboard.viewHistory": "View history →",
};
return map[key] ?? key;
},
}),
}));

vi.mock("next/image", () => ({
// eslint-disable-next-line @next/next/no-img-element
default: (props: React.ImgHTMLAttributes<HTMLImageElement>) => <img {...props} />,

Check warning on line 23 in frontend/src/components/dashboard/RecentlyViewed.test.tsx

View workflow job for this annotation

GitHub Actions / Typecheck & Lint

img elements must have an alt prop, either with meaningful text, or an empty string for decorative images
}));

// ─── Helpers ────────────────────────────────────────────────────────────────

function makeProduct(
Expand Down Expand Up @@ -119,11 +125,11 @@
expect(link).toHaveAttribute("href", "/app/product/42");
});

it("shows View all link", () => {
it("shows View history link", () => {
const products = [makeProduct(1)];
render(<RecentlyViewed products={products} />);

const link = screen.getByRole("link", { name: /View all/ });
const link = screen.getByRole("link", { name: /View history/ });
expect(link).toHaveAttribute("href", "/app/search");
});

Expand All @@ -140,4 +146,73 @@
expect(screen.getByText("Product 1")).toBeInTheDocument();
expect(screen.queryByText("Brand 1")).not.toBeInTheDocument();
});

// ─── Image / badge / animation enrichment tests ─────────────────────────

it("renders product image when image_thumb_url is provided", () => {
const products = [
makeProduct(1, {
image_thumb_url: "https://images.openfoodfacts.org/images/products/test.jpg",
}),
];
render(<RecentlyViewed products={products} />);

const img = screen.getByAltText("Product 1");
expect(img).toBeInTheDocument();
expect(img.tagName).toBe("IMG");
expect(img).toHaveAttribute(
"src",
"https://images.openfoodfacts.org/images/products/test.jpg",
);
});

it("renders initial fallback when no image", () => {
const products = [makeProduct(1, { image_thumb_url: null })];
render(<RecentlyViewed products={products} />);

// Score circle shows TryVit score (unhealthiness 40 → TryVit 60)
const item = screen.getByTestId("recently-viewed-item");
expect(item.textContent).toContain("60");
// No <img> with product alt text
expect(screen.queryByAltText("Product 1")).not.toBeInTheDocument();
});

it("renders NutriScoreBadge when nutri_score_label is present", () => {
const products = [makeProduct(1, { nutri_score_label: "C" })];
render(<RecentlyViewed products={products} />);

expect(screen.getByLabelText("Nutri-Score C")).toBeInTheDocument();
});

it("omits NutriScoreBadge when nutri_score_label is null", () => {
const products = [makeProduct(1, { nutri_score_label: null })];
render(<RecentlyViewed products={products} />);

expect(screen.queryByLabelText(/Nutri-Score/)).not.toBeInTheDocument();
});

it("renders time as styled pill", () => {
const products = [makeProduct(1)];
render(<RecentlyViewed products={products} />);

const timePill = screen.getByText("now");
expect(timePill).toBeInTheDocument();
expect(timePill.className).toContain("rounded-full");
expect(timePill.className).toContain("bg-surface-secondary");
});

it("applies staggered slide-in-right animation delays", () => {
const products = [makeProduct(1), makeProduct(2), makeProduct(3)];
render(<RecentlyViewed products={products} />);

const items = screen.getAllByTestId("recently-viewed-item");
expect(items).toHaveLength(3);

items.forEach((link, i) => {
// The animated wrapper is the parent <div> of the <a> link
const wrapper = link.parentElement as HTMLElement;
expect(wrapper.className).toContain("animate-slide-in-right");
expect(wrapper.style.animationDelay).toBe(`${i * 30}ms`);
});
});
});
79 changes: 51 additions & 28 deletions frontend/src/components/dashboard/RecentlyViewed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

// ─── RecentlyViewed — compact recently viewed product list ──────────────────

import { NutriScoreBadge } from "@/components/common/NutriScoreBadge";
import { useTranslation } from "@/lib/i18n";
import { getScoreBand, toTryVitScore } from "@/lib/score-utils";
import type { RecentlyViewedProduct } from "@/lib/types";
import { ArrowRight } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { useMemo } from "react";

Expand Down Expand Up @@ -54,13 +56,13 @@ export function RecentlyViewed({ products }: Readonly<RecentlyViewedProps>) {
href="/app/search"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{t("dashboard.viewAll")}
{t("dashboard.viewHistory")}
<ArrowRight className="h-3 w-3" aria-hidden="true" />
</Link>
</div>

<div className="space-y-2">
{items.map((product) => {
{items.map((product, index) => {
const tryVit =
product.unhealthiness_score != null
? toTryVitScore(product.unhealthiness_score)
Expand All @@ -72,38 +74,59 @@ export function RecentlyViewed({ products }: Readonly<RecentlyViewedProps>) {
const timeAgo = relativeTimeAgo(product.viewed_at);

return (
<Link
<div
key={product.product_id}
href={`/app/product/${product.product_id}`}
data-testid="recently-viewed-item"
className="card hover-lift-press flex items-center gap-3 px-3 py-2.5 transition-shadow hover:shadow-md"
className="animate-slide-in-right"
style={{ animationDelay: `${index * 30}ms`, animationFillMode: "both" }}
>
Comment on lines 76 to 81
{/* Score circle */}
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${band?.bgColor ?? "bg-muted"}`}
<Link
href={`/app/product/${product.product_id}`}
data-testid="recently-viewed-item"
className="card hover-lift-press flex items-center gap-3 px-3 py-2.5 transition-shadow hover:shadow-md"
>
<span
className={`text-xs font-bold tabular-nums ${band?.textColor ?? "text-foreground-secondary"}`}
>
{tryVit ?? "–"}
</span>
</div>
{/* Product thumbnail or score circle fallback */}
{product.image_thumb_url ? (
<Image
src={product.image_thumb_url}
alt={product.product_name}
width={36}
height={36}
className="h-9 w-9 shrink-0 rounded-lg object-cover"
loading="lazy"
/>
) : (
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${band?.bgColor ?? "bg-muted"}`}
>
<span
className={`text-xs font-bold tabular-nums ${band?.textColor ?? "text-foreground-secondary"}`}
>
{tryVit ?? "–"}
</span>
</div>
)}
Comment on lines +87 to +107

{/* Name + brand */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{product.product_name}</p>
{product.brand && (
<p className="truncate text-xs text-foreground-secondary">
{product.brand}
</p>
{/* Name + brand */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{product.product_name}</p>
{product.brand && (
<p className="truncate text-xs text-foreground-secondary">
{product.brand}
</p>
)}
</div>

{/* NutriScore badge */}
{product.nutri_score_label && (
<NutriScoreBadge grade={product.nutri_score_label} size="sm" />
)}
</div>

{/* Relative time */}
<span className="shrink-0 text-xs tabular-nums text-foreground-secondary">
{timeAgo}
</span>
</Link>
{/* Relative time pill */}
<span className="shrink-0 rounded-full bg-surface-secondary px-2 py-0.5 text-xs tabular-nums text-foreground-secondary">
{timeAgo}
</span>
</Link>
</div>
);
})}
</div>
Expand Down
32 changes: 26 additions & 6 deletions frontend/src/styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,30 @@
--animate-trust-verified: trust-verified 0.6s var(--ease-decelerate) 0.3s both;
--animate-shake: shake 0.5s ease-in-out both;
--animate-pulse-glow: pulse-glow 2s ease-in-out infinite;
--animate-slide-in-right: slideInRight var(--duration-normal) var(--ease-decelerate) both;

@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }

0%,
100% {
transform: translateX(0);
}

20% {
transform: translateX(-6px);
}

40% {
transform: translateX(6px);
}

60% {
transform: translateX(-4px);
}

80% {
transform: translateX(4px);
}
}

@keyframes fade-in-up {
Expand Down Expand Up @@ -216,9 +233,12 @@
}

@keyframes pulse-glow {
0%, 100% {

0%,
100% {
box-shadow: 0 0 0 0 rgb(var(--color-brand) / 0.4);
}

50% {
box-shadow: 0 0 0 6px rgb(var(--color-brand) / 0);
}
Expand Down
Loading