Add active-product-count-driven automation

- Products gain `active`/`spotlight*`/`updatedAt` on the base Product type
  (folded in from the now-removed separate SpotlightProduct type) so shop
  grid, spotlight, and related-products can each filter `.active` from the
  same already-fetched list — cart/checkout/order-confirmation/product-
  detail pages keep resolving any product regardless of active status.
- getSpotlightProduct() now derives from getProducts() instead of its own
  Payload query: with exactly 1 active product, that one IS the spotlight
  (overriding any `spotlight` flag elsewhere); otherwise same
  most-recently-updated tie-break as before, just computed client-side.
- ProductGrid drops the already-dead SHOP_GRID_EXCLUDE_IDS list in favor of
  the same `active` filter, with an empty-state message if 0 active.
- RelatedProducts gates on >=2 active products regardless of cart contents
  or how many display slots would otherwise resolve.
- Navbar's "Shop" link becomes an anchor to the homepage spotlight section
  (id="spotlight") instead of a real /shop navigation whenever exactly 1
  product is active — passed down from the now-async root layout, which
  fetches the catalog once for this decision.
This commit is contained in:
Marco
2026-07-21 20:39:22 +00:00
parent 37b710c933
commit 028a1fc4ec
6 changed files with 108 additions and 76 deletions
+13 -4
View File
@@ -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<typeof p> => 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 (
<section className="w-full bg-bg-base flex flex-col gap-8 items-center py-12 md:py-16 px-[var(--layout-padding-x)]">
+25 -14
View File
@@ -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<HTMLDivElement>(null);
const hamburgerRef = useRef<HTMLButtonElement>(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
+4 -1
View File
@@ -29,7 +29,10 @@ export async function ProductSpotlight() {
const discount = discountPercent(product.price, product.compareAtPrice);
return (
<section className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
// 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).
<section id="spotlight" className="w-full bg-bg-base py-12 md:py-16 px-[var(--layout-padding-x)]">
<Reveal className="max-w-[75rem] mx-auto rounded-md flex flex-col md:flex-row gap-8 md:gap-12 items-center p-6 md:p-10">
<div className="group relative w-full md:w-[23.75rem] md:shrink-0 aspect-[410/227] rounded-sm overflow-hidden">
<Image
+10 -2
View File
@@ -3,6 +3,7 @@ import { Inter, Playfair_Display, Caveat, Lora } from "next/font/google";
import "./globals.css";
import { Navbar } from "./components/Navbar";
import { CartFlyProvider } from "./components/CartFly";
import { getProducts } from "./lib/payload";
const inter = Inter({
variable: "--font-inter",
@@ -45,11 +46,18 @@ export const metadata: Metadata = {
},
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
// 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 (
<html
lang="de"
@@ -57,7 +65,7 @@ export default function RootLayout({
>
<body className="min-h-full flex flex-col">
<CartFlyProvider>
<Navbar />
<Navbar singleActiveProduct={singleActiveProduct} />
{children}
</CartFlyProvider>
</body>
+47 -49
View File
@@ -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<Product | null> {
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<Product | null> {
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<SpotlightProduct | null> {
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 };
+9 -6
View File
@@ -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 (
<section className="w-full bg-bg-base flex flex-col items-center pb-16 md:pb-20 px-[var(--layout-padding-x)]">
<p className="text-body text-text-muted">Aktuell keine Produkte verfügbar.</p>
</section>
);
}
return (
<section className="w-full bg-bg-base flex flex-col pb-16 md:pb-20 px-[var(--layout-padding-x)]">