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
+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 };