diff --git a/app/cart/components/RelatedProducts.tsx b/app/cart/components/RelatedProducts.tsx index 26d6bff..61b5221 100644 --- a/app/cart/components/RelatedProducts.tsx +++ b/app/cart/components/RelatedProducts.tsx @@ -40,16 +40,20 @@ function pickWithFallback(allIds: string[], excludeIds: string[], keep: string[] export function RelatedProducts() { const cart = useCart(); const products = useProducts(); + // Cart/checkout resolve any product regardless of `active` (see + // Product's own comment in lib/payload.ts) — this is the one discovery + // surface among the useProducts() consumers, so it filters here itself. + const activeProducts = useMemo(() => products.filter((p) => p.active), [products]); const hasItems = cart.length > 0; const cartKey = cart .map((i) => i.id) .sort() .join(","); // useMemo, not a plain .map() — .map() would return a new array - // reference on every render regardless of whether `products` itself + // reference on every render regardless of whether `activeProducts` itself // changed, which would make the effect below re-run (and re-pick) every // single render if `productIds` were listed as its dependency. - const productIds = useMemo(() => products.map((p) => p.id), [products]); + const productIds = useMemo(() => activeProducts.map((p) => p.id), [activeProducts]); // Starts empty — the catalog itself is now fetched (useProducts()), so // there's nothing to pick a random set from until that resolves. The @@ -94,10 +98,15 @@ export function RelatedProducts() { }, [cartKey, productIds]); const displayProducts = displayIds - .map((id) => products.find((p) => p.id === id)) + .map((id) => activeProducts.find((p) => p.id === id)) .filter((p): p is NonNullable => Boolean(p)); - if (displayProducts.length === 0) return null; + // Section-wide gate, independent of cart contents or how many + // displayProducts happen to resolve: with only 1 active product, + // pickWithFallback's cart-item-reuse fallback could still populate a + // card, but a "related products" section makes no sense with fewer than + // 2 real alternatives to offer. + if (activeProducts.length < 2 || displayProducts.length === 0) return null; return (
diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx index ac15dac..7458d29 100644 --- a/app/components/Navbar.tsx +++ b/app/components/Navbar.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import Image from "next/image"; import { usePathname } from "next/navigation"; @@ -31,16 +31,21 @@ function smoothScrollTo(targetY: number) { requestAnimationFrame(step); } -const navLinks = [ - { label: "Werkzeuge", href: "#werkzeuge" }, - { label: "Blog", href: "/blog" }, - { label: "Über Björn", href: "#ueber-bjoern" }, - { label: "Shop", href: "/shop" }, -]; - -const anchorIds = navLinks - .filter((l) => l.href.startsWith("#")) - .map((l) => l.href.slice(1)); +// "Shop" becomes an in-page anchor to the homepage's ProductSpotlight +// section (id="spotlight") instead of a real /shop navigation whenever +// exactly 1 product is active — same reasoning as the other anchor links, +// #werkzeuge/#ueber-bjoern already have (a full catalog grid is +// degenerate UX with only 1 item to show). Passed down from +// app/layout.tsx, which is the one place already fetching the product +// catalog for this decision. +function getNavLinks(singleActiveProduct: boolean) { + return [ + { label: "Werkzeuge", href: "#werkzeuge" }, + { label: "Blog", href: "/blog" }, + { label: "Über Björn", href: "#ueber-bjoern" }, + { label: "Shop", href: singleActiveProduct ? "#spotlight" : "/shop" }, + ]; +} // "Werkzeuge" also covers standalone tool/product pages that live under // the Home "Werkzeuge" section conceptually — /todo-cards (ToDo-Karten), @@ -145,7 +150,7 @@ function CartLink() { ); } -export function Navbar() { +export function Navbar({ singleActiveProduct }: { singleActiveProduct: boolean }) { const pathname = usePathname(); const [scrolled, setScrolled] = useState(false); const [activeSection, setActiveSection] = useState(""); @@ -154,6 +159,12 @@ export function Navbar() { const panelRef = useRef(null); const hamburgerRef = useRef(null); + const navLinks = useMemo(() => getNavLinks(singleActiveProduct), [singleActiveProduct]); + const anchorIds = useMemo( + () => navLinks.filter((l) => l.href.startsWith("#")).map((l) => l.href.slice(1)), + [navLinks] + ); + useEffect(() => { const onScroll = () => setScrolled(window.scrollY > 8); window.addEventListener("scroll", onScroll, { passive: true }); @@ -197,7 +208,7 @@ export function Navbar() { } }, 50); return () => clearTimeout(timer); - }, [pathname]); + }, [pathname, anchorIds]); useEffect(() => { const onScroll = () => { @@ -216,7 +227,7 @@ export function Navbar() { onScroll(); window.addEventListener("scroll", onScroll, { passive: true }); return () => window.removeEventListener("scroll", onScroll); - }, []); + }, [anchorIds]); // Close on viewport resize past the structural breakpoint, so the drawer // never lingers open behind the (now visible) desktop nav. The hamburger diff --git a/app/components/ProductSpotlight.tsx b/app/components/ProductSpotlight.tsx index 34957d9..d6d6323 100644 --- a/app/components/ProductSpotlight.tsx +++ b/app/components/ProductSpotlight.tsx @@ -29,7 +29,10 @@ export async function ProductSpotlight() { const discount = discountPercent(product.price, product.compareAtPrice); return ( -
+ // id="spotlight" — the Navbar's "Shop" link becomes an anchor to this + // section instead of navigating to /shop whenever exactly 1 product is + // active (see Navbar.tsx/layout.tsx). +
) { + // Same 60s-ISR-cached call every other page already makes — reused here + // just to know whether Navbar's "Shop" link should behave as an anchor + // to the homepage spotlight instead of a real /shop navigation (see + // Navbar.tsx/ProductSpotlight.tsx). + const products = await getProducts(); + const singleActiveProduct = products.filter((p) => p.active).length === 1; + return ( - + {children} diff --git a/app/lib/payload.ts b/app/lib/payload.ts index 71e7dab..be57470 100644 --- a/app/lib/payload.ts +++ b/app/lib/payload.ts @@ -156,6 +156,20 @@ export type Product = { compareAtPrice: number | null; image: string; href: string | null; + // `active` is opt-in for callers to filter by, not applied inside + // getProducts()/getProductBySlug() themselves — cart, checkout, order + // confirmation, and already-linked product detail pages (e.g. + // TodoKartenHero/Pricing calling getProductBySlug directly) all need to + // keep resolving a product regardless of its active status, unlike the + // shop grid / spotlight / related-products discovery surfaces, which + // filter `.filter(p => p.active)` themselves. + active: boolean; + updatedAt: string; + spotlight: boolean; + spotlightEyebrow: string | null; + spotlightHeadline: string | null; + spotlightText: string | null; + spotlightImage: string | null; }; type PayloadProduct = { @@ -167,6 +181,13 @@ type PayloadProduct = { compareAtPrice: number | null; image: { url: string } | number | null; detailHref: string | null; + active: boolean; + updatedAt: string; + spotlight: boolean; + spotlightEyebrow: string | null; + spotlightHeadline: string | null; + spotlightText: string | null; + spotlightImage: { url: string } | number | null; }; // Shared by getProducts() and getPostBySlug()'s relatedProduct — kept in @@ -182,6 +203,14 @@ export function mapPayloadProduct(product: PayloadProduct): Product { compareAtPrice: product.compareAtPrice ?? null, image: typeof product.image === "object" && product.image ? product.image.url : "", href: product.detailHref || null, + active: product.active, + updatedAt: product.updatedAt, + spotlight: product.spotlight, + spotlightEyebrow: product.spotlightEyebrow || null, + spotlightHeadline: product.spotlightHeadline || null, + spotlightText: product.spotlightText || null, + spotlightImage: + typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null, }; } @@ -211,57 +240,26 @@ export async function getProductBySlug(slug: string): Promise { return products.find((p) => p.id === slug) ?? null; } -export type SpotlightProduct = Product & { - spotlightEyebrow: string | null; - spotlightHeadline: string | null; - spotlightText: string | null; - spotlightImage: string | null; -}; +// Derived from getProducts() (same 60s-ISR-cached fetch every other +// discovery surface already uses) instead of its own separate Payload +// query — also what lets the auto-spotlight rule below just be a plain +// array check instead of a second round-trip. +// +// Auto-spotlight: with exactly 1 active product, that product IS the +// spotlight, full stop — overriding any `spotlight` flag set on some +// other (inactive) product. Confirmed product decision, not just a +// no-manual-flag fallback. Otherwise, same deterministic tie-break as +// before (most-recently-updated wins) among active products actually +// flagged `spotlight`. +export async function getSpotlightProduct(): Promise { + const products = await getProducts(); + const active = products.filter((p) => p.active); -type PayloadSpotlightProduct = PayloadProduct & { - spotlightEyebrow: string | null; - spotlightHeadline: string | null; - spotlightText: string | null; - spotlightImage: { url: string } | number | null; -}; + if (active.length === 1) return active[0]; -// sort: "-spotlight,-updatedAt" — same deterministic-tie-breaker pattern -// as getBlogPosts' featured post: if more than one product is accidentally -// marked spotlight, the most recently updated one wins, no error. -export async function getSpotlightProduct(): Promise { - const params = new URLSearchParams({ - "where[tenant.slug][equals]": TENANT_SLUG, - "where[spotlight][equals]": "true", - sort: "-updatedAt", - depth: "2", - limit: "1", - }); - - const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { - next: { revalidate: 60 }, - }); - if (!res.ok) { - console.error(`getSpotlightProduct: Payload returned ${res.status} ${res.statusText}`); - return null; - } - - const data: { docs?: PayloadSpotlightProduct[] } = await res.json(); - const doc = data.docs?.[0]; - if (!doc) return null; - return { - id: doc.slug, - name: doc.name, - description: doc.description ?? "", - price: doc.price, - compareAtPrice: doc.compareAtPrice ?? null, - image: typeof doc.image === "object" && doc.image ? doc.image.url : "", - href: doc.detailHref || null, - spotlightEyebrow: doc.spotlightEyebrow || null, - spotlightHeadline: doc.spotlightHeadline || null, - spotlightText: doc.spotlightText || null, - spotlightImage: - typeof doc.spotlightImage === "object" && doc.spotlightImage ? doc.spotlightImage.url : null, - }; + const flagged = active.filter((p) => p.spotlight); + if (flagged.length === 0) return null; + return flagged.reduce((latest, p) => (p.updatedAt > latest.updatedAt ? p : latest)); } export type TrustBadge = { id: number; title: string; description: string; icon: string }; diff --git a/app/shop/components/ProductGrid.tsx b/app/shop/components/ProductGrid.tsx index 411b3a7..5a4abc7 100644 --- a/app/shop/components/ProductGrid.tsx +++ b/app/shop/components/ProductGrid.tsx @@ -10,15 +10,18 @@ import { AddToCartInlineButton } from "../../components/AddToCartInlineButton"; // hook /cart's components need; this grid doesn't react to cart state, so // there's no reason to pay for a client fetch when a server one already // gives faster first paint and no loading flash. -// "notizbuch-klarheit" is deliberately excluded from the shop grid — it -// has no card in Figma's page-shop-overview (only 4 products do), even -// though it's a real product in Payload. Still shown elsewhere as a -// cross-sell (RelatedProducts on /cart). -const SHOP_GRID_EXCLUDE_IDS = ["notizbuch-klarheit"]; export async function ProductGrid() { const [allProducts, shipping] = await Promise.all([getProducts(), getShippingSettings()]); - const products = allProducts.filter((p) => !SHOP_GRID_EXCLUDE_IDS.includes(p.id)); + const products = allProducts.filter((p) => p.active); + + if (products.length === 0) { + return ( +
+

Aktuell keine Produkte verfügbar.

+
+ ); + } return (