From 20a349916742c8bbcd860be892a5f9ee2cfbcc9d Mon Sep 17 00:00:00 2001 From: ericsocrat Date: Thu, 19 Mar 2026 19:16:59 +0100 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20RecentlyViewed=20enrichment?= =?UTF-8?q?=20=E2=80=94=20images,=20NutriScore=20badges,=20time=20pills=20?= =?UTF-8?q?(#964)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/pl.json | 1 + .../dashboard/RecentlyViewed.test.tsx | 79 ++++++++++++++++++- .../components/dashboard/RecentlyViewed.tsx | 79 ++++++++++++------- frontend/src/styles/globals.css | 32 ++++++-- 6 files changed, 157 insertions(+), 36 deletions(-) diff --git a/frontend/messages/de.json b/frontend/messages/de.json index c82539ff..5fdde62b 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -117,6 +117,7 @@ "newProducts": "Neue Produkte", "newInCategory": "Neu in {category}", "viewAll": "Alle anzeigen →", + "viewHistory": "Verlauf anzeigen →", "browse": "Durchsuchen →", "scanned": "Gescannt", "viewed": "Angesehen", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 72c36c55..ec25813f 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -117,6 +117,7 @@ "newProducts": "New Products", "newInCategory": "New {category}", "viewAll": "View all →", + "viewHistory": "View history →", "browse": "Browse →", "scanned": "Scanned", "viewed": "Viewed", diff --git a/frontend/messages/pl.json b/frontend/messages/pl.json index ddee6a4f..424faabe 100644 --- a/frontend/messages/pl.json +++ b/frontend/messages/pl.json @@ -117,6 +117,7 @@ "newProducts": "Nowe produkty", "newInCategory": "Nowe {category}", "viewAll": "Pokaż wszystko →", + "viewHistory": "Historia →", "browse": "Przeglądaj →", "scanned": "Zeskanowane", "viewed": "Przeglądane", diff --git a/frontend/src/components/dashboard/RecentlyViewed.test.tsx b/frontend/src/components/dashboard/RecentlyViewed.test.tsx index 9d144169..1277774e 100644 --- a/frontend/src/components/dashboard/RecentlyViewed.test.tsx +++ b/frontend/src/components/dashboard/RecentlyViewed.test.tsx @@ -11,12 +11,18 @@ vi.mock("@/lib/i18n", () => ({ const map: Record = { "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) => , +})); + // ─── Helpers ──────────────────────────────────────────────────────────────── function makeProduct( @@ -119,11 +125,11 @@ describe("RecentlyViewed", () => { expect(link).toHaveAttribute("href", "/app/product/42"); }); - it("shows View all link", () => { + it("shows View history link", () => { const products = [makeProduct(1)]; render(); - const link = screen.getByRole("link", { name: /View all/ }); + const link = screen.getByRole("link", { name: /View history/ }); expect(link).toHaveAttribute("href", "/app/search"); }); @@ -140,4 +146,73 @@ describe("RecentlyViewed", () => { 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(); + + 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(); + + // Score circle shows TryVit score (unhealthiness 40 → TryVit 60) + const item = screen.getByTestId("recently-viewed-item"); + expect(item.textContent).toContain("60"); + // No 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(); + + 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(); + + expect(screen.queryByLabelText(/Nutri-Score/)).not.toBeInTheDocument(); + }); + + it("renders time as styled pill", () => { + const products = [makeProduct(1)]; + render(); + + 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(); + + const items = screen.getAllByTestId("recently-viewed-item"); + expect(items).toHaveLength(3); + + items.forEach((link, i) => { + // The animated wrapper is the parent
of the link + const wrapper = link.parentElement as HTMLElement; + expect(wrapper.className).toContain("animate-slide-in-right"); + expect(wrapper.style.animationDelay).toBe(`${i * 30}ms`); + }); + }); }); diff --git a/frontend/src/components/dashboard/RecentlyViewed.tsx b/frontend/src/components/dashboard/RecentlyViewed.tsx index 3cc107b9..243ae739 100644 --- a/frontend/src/components/dashboard/RecentlyViewed.tsx +++ b/frontend/src/components/dashboard/RecentlyViewed.tsx @@ -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"; @@ -54,13 +56,13 @@ export function RecentlyViewed({ products }: Readonly) { href="/app/search" className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline" > - {t("dashboard.viewAll")} + {t("dashboard.viewHistory")}
- {items.map((product) => { + {items.map((product, index) => { const tryVit = product.unhealthiness_score != null ? toTryVitScore(product.unhealthiness_score) @@ -72,38 +74,59 @@ export function RecentlyViewed({ products }: Readonly) { const timeAgo = relativeTimeAgo(product.viewed_at); return ( - - {/* Score circle */} -
- - {tryVit ?? "–"} - -
+ {/* Product thumbnail or score circle fallback */} + {product.image_thumb_url ? ( + {product.product_name} + ) : ( +
+ + {tryVit ?? "–"} + +
+ )} - {/* Name + brand */} -
-

{product.product_name}

- {product.brand && ( -

- {product.brand} -

+ {/* Name + brand */} +
+

{product.product_name}

+ {product.brand && ( +

+ {product.brand} +

+ )} +
+ + {/* NutriScore badge */} + {product.nutri_score_label && ( + )} -
- {/* Relative time */} - - {timeAgo} - - + {/* Relative time pill */} + + {timeAgo} + + +
); })} diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 7dda9fe1..ffc59824 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -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 { @@ -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); }