diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 381ebc3..f9249ed 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -52,17 +52,22 @@ function PeopleIcon() { ); } -const STAT_KEYS = [ - "revenueToday", - "revenueMonth", - "openOrders", - "inventory", - "soldGlasses", - "avgCart", - "repeatCustomers", -] as const; +/** + * Die Kennzahlen-Kacheln des Dashboards. Bewusst ein Typ und keine + * Wert-Konstante: die Liste wurde nie zur Laufzeit durchlaufen, sondern + * ausschliesslich benutzt, um die Record-/Array-Typen unten vollstaendig zu + * halten — als `as const`-Array landete sie trotzdem im Bundle. + */ +type StatKey = + | "revenueToday" + | "revenueMonth" + | "openOrders" + | "inventory" + | "soldGlasses" + | "avgCart" + | "repeatCustomers"; -const statIcons: Record<(typeof STAT_KEYS)[number], () => ReturnType> = { +const statIcons: Record ReturnType> = { revenueToday: CoinIcon, revenueMonth: CoinIcon, openOrders: OrdersIcon, @@ -161,7 +166,7 @@ export default async function AdminDashboardPage() { } const repeatCustomerCount = [...ordersPerCustomer.values()].filter((count) => count > 1).length; - const statLabels: Record<(typeof STAT_KEYS)[number], string> = { + const statLabels: Record = { revenueToday: t.adminDashboardPage.statRevenueToday, revenueMonth: t.adminDashboardPage.statRevenueMonth, openOrders: t.adminDashboardPage.statOpenOrders, @@ -171,7 +176,7 @@ export default async function AdminDashboardPage() { repeatCustomers: t.adminDashboardPage.statRepeatCustomers, }; - const stats: { key: (typeof STAT_KEYS)[number]; value: string }[] = [ + const stats: { key: StatKey; value: string }[] = [ { key: "revenueToday", value: formatPrice(revenueToday._sum.totalCents ?? 0) }, { key: "revenueMonth", value: formatPrice(revenueMonth._sum.totalCents ?? 0) }, { key: "openOrders", value: String(openOrders) }, diff --git a/src/components/recently-viewed-section.tsx b/src/components/recently-viewed-section.tsx index 0247b37..1bf6d76 100644 --- a/src/components/recently-viewed-section.tsx +++ b/src/components/recently-viewed-section.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useRecentlyViewedStore } from "@/store/recently-viewed-store"; import { getRecentlyViewedProducts } from "@/app/shop/actions"; import { ProductCard } from "@/components/product-card"; @@ -15,22 +15,29 @@ import type { ProductWithRelations } from "@/lib/data"; export function RecentlyViewedSection({ excludeProductId }: { excludeProductId?: string }) { const { t } = useI18n(); const viewedIds = useRecentlyViewedStore((s) => s.viewedIds); - const [products, setProducts] = useState([]); + const ids = useMemo( + () => viewedIds.filter((id) => id !== excludeProductId), + [viewedIds, excludeProductId] + ); + const [fetched, setFetched] = useState([]); useEffect(() => { - const ids = viewedIds.filter((id) => id !== excludeProductId); - if (ids.length === 0) { - setProducts([]); - return; - } + // Der Leerfall wird bewusst NICHT mehr per setState im Effekt behandelt: + // "keine angesehenen Produkte" ergibt sich unmittelbar aus ids und laesst + // sich beim Rendern ableiten. Ein setState direkt im Effektrumpf loest + // dagegen einen zusaetzlichen Renderdurchlauf aus, nur um einen Zustand + // herzustellen, der ohnehin schon feststand. + if (ids.length === 0) return; let cancelled = false; getRecentlyViewedProducts(ids).then((result) => { - if (!cancelled) setProducts(result); + if (!cancelled) setFetched(result); }); return () => { cancelled = true; }; - }, [viewedIds, excludeProductId]); + }, [ids]); + + const products = ids.length === 0 ? [] : fetched; if (products.length === 0) return null; diff --git a/src/components/site-chrome.tsx b/src/components/site-chrome.tsx index 1336d78..9136081 100644 --- a/src/components/site-chrome.tsx +++ b/src/components/site-chrome.tsx @@ -101,12 +101,17 @@ function SearchButton() { const [suggestions, setSuggestions] = useState([]); const toggleButtonRef = useRef(null); + // "Eingabe zu kurz" ergibt sich unmittelbar aus query und wird beim + // Rendern abgeleitet, statt im Effekt per setState hergestellt zu werden — + // ein setState direkt im Effektrumpf loest einen zusaetzlichen + // Renderdurchlauf aus, nur um einen ohnehin feststehenden Zustand zu + // erzeugen. Nebeneffekt: beim Leeren des Feldes verschwindet die Liste + // sofort, statt erst nach dem naechsten Effektlauf. + const visibleSuggestions = query.trim().length < 2 ? [] : suggestions; + useEffect(() => { const trimmed = query.trim(); - if (trimmed.length < 2) { - setSuggestions([]); - return; - } + if (trimmed.length < 2) return; const controller = new AbortController(); const timeout = setTimeout(() => { fetch(`/api/shop/search-suggestions?q=${encodeURIComponent(trimmed)}`, { @@ -153,9 +158,9 @@ function SearchButton() { placeholder={t.nav.searchPlaceholder} className="w-full border-b border-forest bg-transparent px-2 py-1 text-sm focus:outline-none" /> - {suggestions.length > 0 ? ( + {visibleSuggestions.length > 0 ? (
    - {suggestions.map((s) => ( + {visibleSuggestions.map((s) => (
  • { vi.stubGlobal("fetch", fetchMock); const { createDhlShipmentLabel, DhlApiError } = await import("./dhl"); - await expect( - createDhlShipmentLabel({ - reference: "WH-2026-0004", - weightGrams: 500, - shipper: validShipper, - receiver: validAddress, - }) - ).rejects.toThrow(/Ungültige Anfrage/); + const attempt = createDhlShipmentLabel({ + reference: "WH-2026-0004", + weightGrams: 500, + shipper: validShipper, + receiver: validAddress, + }); + + // Neben der Meldung auch den Fehlertyp pruefen — wie in den beiden Tests + // darueber. Ohne diese Zusage blieb das bereits importierte DhlApiError + // hier ungenutzt, und der Test haette jeden beliebigen anderen Fehler mit + // passendem Text durchgehen lassen. + await expect(attempt).rejects.toThrow(DhlApiError); + await expect(attempt).rejects.toThrow(/Ungültige Anfrage/); }); });