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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
Reference in New Issue
Block a user