feat(blog): optional quote label + swappable related-product card per post

Both were fully hardcoded before: every blockquote showed a static
"Merke dir:" label, and every post's "Passend dazu" card always linked
the same flagship product. Now driven by two new Posts fields —
quoteLabel (empty hides the label/icon/underline, blockquote still
renders) and relatedProduct (empty hides the card entirely) — mirroring
Products.spotlight but per-post instead of a single site-wide flag.

README's collection table updated to match today's Payload changes
(shipping-settings, the new Posts fields, trust-badges' placeholder
tokens, admin sidebar grouping) — also fixed a stale claim that `media`
isn't tenant-scoped; it already was.
This commit is contained in:
Marco
2026-07-21 13:10:06 +00:00
parent 225a8567a9
commit 2d6cff9f40
4 changed files with 140 additions and 90 deletions
+32 -10
View File
@@ -71,9 +71,21 @@ export async function getBlogPosts(limit = 3): Promise<BlogPost[]> {
export type PostDetail = BlogPost & {
content: unknown;
/** Label for every blockquote callout in this post's content (icon +
* underline included) — empty string hides that left side entirely,
* the blockquote itself still renders. See RichText.tsx's "quote" case. */
quoteLabel: string;
/** Powers the "Passend dazu" card at the end of the post — null hides
* the card entirely, per-post choice (unlike Products.spotlight, which
* is a single site-wide flag). */
relatedProduct: Product | null;
};
type PayloadPostDetail = PayloadPost & { content: unknown };
type PayloadPostDetail = PayloadPost & {
content: unknown;
quoteLabel: string | null;
relatedProduct: PayloadProduct | null;
};
export async function getPostBySlug(slug: string): Promise<PostDetail | null> {
const params = new URLSearchParams({
@@ -107,6 +119,8 @@ export async function getPostBySlug(slug: string): Promise<PostDetail | null> {
content: doc.content,
publishedAt: doc.publishedAt,
featured: doc.featured,
quoteLabel: doc.quoteLabel ?? "",
relatedProduct: doc.relatedProduct ? mapPayloadProduct(doc.relatedProduct) : null,
};
}
@@ -136,6 +150,22 @@ type PayloadProduct = {
detailHref: string | null;
};
// Shared by getProducts() and getPostBySlug()'s relatedProduct — kept in
// one place instead of duplicating the same field mapping, which is
// exactly the kind of drift this session's Shipping Settings work was
// about eliminating elsewhere.
function mapPayloadProduct(product: PayloadProduct): Product {
return {
id: product.slug,
name: product.name,
description: product.description ?? "",
price: product.price,
compareAtPrice: product.compareAtPrice ?? null,
image: typeof product.image === "object" && product.image ? product.image.url : "",
href: product.detailHref || null,
};
}
export async function getProducts(): Promise<Product[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
@@ -154,15 +184,7 @@ export async function getProducts(): Promise<Product[]> {
const data: { docs?: PayloadProduct[] } = await res.json();
const docs = Array.isArray(data.docs) ? data.docs : [];
return docs.map((product) => ({
id: product.slug,
name: product.name,
description: product.description ?? "",
price: product.price,
compareAtPrice: product.compareAtPrice ?? null,
image: typeof product.image === "object" && product.image ? product.image.url : "",
href: product.detailHref || null,
}));
return docs.map(mapPayloadProduct);
}
export async function getProductBySlug(slug: string): Promise<Product | null> {