Files
einfach-produktiv/app/lib/payload.ts
T
Marco 2d6cff9f40 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.
2026-07-21 13:10:06 +00:00

542 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { formatPrice } from "./format";
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
const TENANT_SLUG = "einfach-produktiv";
export type BlogPost = {
id: number;
title: string;
slug: string;
category: string;
readTime: number;
excerpt: string;
thumbnail: string | null;
publishedAt: string;
featured: boolean;
};
type PayloadPost = {
id: number;
title: string;
slug: string;
category: { name: string } | number | null;
readTime: number;
excerpt: string;
thumbnail: { url: string } | number | null;
publishedAt: string;
featured: boolean;
};
// sort: "-featured,-publishedAt" — Payload's multi-field sort puts any
// featured post(s) first, most-recently-published first among those, then
// the rest by date. Callers that just want posts[0] as "the" featured post
// (e.g. /blog) get a deterministic pick even if more than one post was
// accidentally marked featured — no special-casing needed here.
export async function getBlogPosts(limit = 3): Promise<BlogPost[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
sort: "-featured,-publishedAt",
depth: "2",
limit: String(limit),
});
const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getBlogPosts: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: PayloadPost[] } = await res.json();
const docs = Array.isArray(data.docs) ? data.docs : [];
return docs.map((post) => ({
id: post.id,
title: post.title,
slug: post.slug,
category:
typeof post.category === "object" && post.category
? post.category.name
: "",
readTime: post.readTime,
excerpt: post.excerpt,
thumbnail:
typeof post.thumbnail === "object" && post.thumbnail
? post.thumbnail.url
: null,
publishedAt: post.publishedAt,
featured: post.featured,
}));
}
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;
quoteLabel: string | null;
relatedProduct: PayloadProduct | null;
};
export async function getPostBySlug(slug: string): Promise<PostDetail | null> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[slug][equals]": slug,
depth: "2",
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getPostBySlug: Payload returned ${res.status} ${res.statusText}`);
return null;
}
const data: { docs?: PayloadPostDetail[] } = await res.json();
const doc = data.docs?.[0];
if (!doc) return null;
return {
id: doc.id,
title: doc.title,
slug: doc.slug,
category:
typeof doc.category === "object" && doc.category ? doc.category.name : "",
readTime: doc.readTime,
excerpt: doc.excerpt,
thumbnail:
typeof doc.thumbnail === "object" && doc.thumbnail ? doc.thumbnail.url : null,
content: doc.content,
publishedAt: doc.publishedAt,
featured: doc.featured,
quoteLabel: doc.quoteLabel ?? "",
relatedProduct: doc.relatedProduct ? mapPayloadProduct(doc.relatedProduct) : null,
};
}
// Frontend-facing shape — `id` is Payload's `slug` field, not its numeric
// row id. Cart items are stored in localStorage keyed by this string (see
// lib/cart.ts), so slugs were chosen in the Products collection to match
// the ids the old hardcoded catalog used ("todo-karten" etc.) — switching
// to numeric ids here would silently orphan every existing shopper's cart.
export type Product = {
id: string;
name: string;
description: string;
price: number;
compareAtPrice: number | null;
image: string;
href: string | null;
};
type PayloadProduct = {
id: number;
name: string;
slug: string;
description: string | null;
price: number;
compareAtPrice: number | null;
image: { url: string } | number | null;
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,
sort: "sortOrder",
depth: "2",
limit: "100",
});
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getProducts: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: PayloadProduct[] } = await res.json();
const docs = Array.isArray(data.docs) ? data.docs : [];
return docs.map(mapPayloadProduct);
}
export async function getProductBySlug(slug: string): Promise<Product | null> {
const products = await getProducts();
return products.find((p) => p.id === slug) ?? null;
}
export type SpotlightProduct = Product & {
spotlightEyebrow: string | null;
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: string | null;
};
type PayloadSpotlightProduct = PayloadProduct & {
spotlightEyebrow: string | null;
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: { url: string } | number | null;
};
// 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,
};
}
export type TrustBadge = { id: number; title: string; description: string; icon: string };
type PayloadTrustBadge = { id: number; title: string; description: string; icon: { url: string } | number | null };
async function fetchTrustBadgeList(collection: "trust-badges" | "cart-trust-badges"): Promise<TrustBadge[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
sort: "sortOrder",
depth: "1",
limit: "50",
});
const res = await fetch(`${PAYLOAD_URL}/api/${collection}?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`fetchTrustBadgeList(${collection}): Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: PayloadTrustBadge[] } = await res.json();
const docs = Array.isArray(data.docs) ? data.docs : [];
return docs.map((doc) => ({
id: doc.id,
title: doc.title,
description: doc.description,
icon: typeof doc.icon === "object" && doc.icon ? doc.icon.url : "",
}));
}
// Substitutes literal "{{lieferzeit}}" / "{{kostenfreiab}}" tokens (e.g. in
// a badge's "In {{lieferzeit}} bei dir." or "Ab {{kostenfreiab}}
// Bestellwert innerhalb DE." copy) with the live delivery-time range /
// free-shipping threshold — lets an editor reference these numbers from
// free text without duplicating and hand-maintaining them, which is
// exactly how the "Schneller Versand" and "Versandkostenfrei" badges used
// to drift from the real numbers whenever those changed elsewhere but not
// here too. The threshold comes from the lowest freeShippingThreshold
// among active ShippingMethods (same rule /cart's own banner uses), not
// lib/shipping.ts's separate FREE_SHIPPING_THRESHOLD constant — that
// constant is a third, independent copy of the same fact and not
// necessarily what actually governs checkout.
function resolveShippingTokens(text: string, shipping: ShippingSettings, freeShippingThreshold: number | null): string {
let result = text.replaceAll("{{lieferzeit}}", `${shipping.totalDays.min}${shipping.totalDays.max} Werktagen`);
if (freeShippingThreshold !== null) {
result = result.replaceAll("{{kostenfreiab}}", formatPrice(freeShippingThreshold));
}
return result;
}
// Powers TrustRow.tsx — the horizontal "Schneller Versand /
// Versandkostenfrei / Mit Liebe verpackt" row.
export async function getTrustBadges(): Promise<TrustBadge[]> {
const [badges, shipping, shippingMethods] = await Promise.all([
fetchTrustBadgeList("trust-badges"),
getShippingSettings(),
getShippingMethods(),
]);
const thresholds = shippingMethods
.map((m) => m.freeShippingThreshold)
.filter((t): t is number => t !== null);
const freeShippingThreshold = thresholds.length > 0 ? Math.min(...thresholds) : null;
return badges.map((b) => ({
...b,
title: resolveShippingTokens(b.title, shipping, freeShippingThreshold),
description: resolveShippingTokens(b.description, shipping, freeShippingThreshold),
}));
}
// Powers the "Sichere Zahlung / 14 Tage Rückgaberecht / Nachhaltig
// verpackt" sidebar bullets on /cart (title only) and /checkout
// (title + description) — a different list from TrustBadges.
export async function getCartTrustBadges(): Promise<TrustBadge[]> {
return fetchTrustBadgeList("cart-trust-badges");
}
export type ShippingMethod = {
id: number;
title: string;
description: string;
price: number;
freeShippingThreshold: number | null;
};
type PayloadShippingMethod = ShippingMethod & { active: boolean };
export async function getShippingMethods(): Promise<ShippingMethod[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[active][equals]": "true",
sort: "sortOrder",
limit: "20",
});
const res = await fetch(`${PAYLOAD_URL}/api/shipping-methods?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getShippingMethods: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: PayloadShippingMethod[] } = await res.json();
const docs = Array.isArray(data.docs) ? data.docs : [];
return docs.map((doc) => ({
id: doc.id,
title: doc.title,
description: doc.description,
price: doc.price,
freeShippingThreshold: doc.freeShippingThreshold ?? null,
}));
}
export type ShippingSettings = {
handlingDays: { min: number; max: number };
transitDays: { min: number; max: number };
/** Derived here, not stored in Payload — a third independently-editable
* copy of the same fact is exactly the drift this collection replaces. */
totalDays: { min: number; max: number };
};
type PayloadShippingSettings = {
handlingDaysMin: number;
handlingDaysMax: number;
transitDaysMin: number;
transitDaysMax: number;
};
// Falls back to the site's long-standing real-world numbers (12 handling,
// 24 transit) if Payload has no row yet or the fetch fails — same values
// the old hardcoded HANDLING_DAYS/TRANSIT_DAYS_DE constants used, so
// nothing regresses before this collection gets seeded/edited.
const SHIPPING_SETTINGS_FALLBACK: ShippingSettings = {
handlingDays: { min: 1, max: 2 },
transitDays: { min: 2, max: 4 },
totalDays: { min: 3, max: 6 },
};
export async function getShippingSettings(): Promise<ShippingSettings> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/shipping-settings?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getShippingSettings: Payload returned ${res.status} ${res.statusText}`);
return SHIPPING_SETTINGS_FALLBACK;
}
const data: { docs?: PayloadShippingSettings[] } = await res.json();
const doc = Array.isArray(data.docs) ? data.docs[0] : undefined;
if (!doc) return SHIPPING_SETTINGS_FALLBACK;
const handlingDays = { min: doc.handlingDaysMin, max: doc.handlingDaysMax };
const transitDays = { min: doc.transitDaysMin, max: doc.transitDaysMax };
return {
handlingDays,
transitDays,
totalDays: { min: handlingDays.min + transitDays.min, max: handlingDays.max + transitDays.max },
};
}
export type PaymentMethod = { id: number; title: string; icons: string[] };
type PayloadPaymentMethod = {
id: number;
title: string;
active: boolean;
icons: { icon: { url: string } | number | null }[];
};
export async function getPaymentMethods(): Promise<PaymentMethod[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[active][equals]": "true",
sort: "sortOrder",
depth: "1",
limit: "20",
});
const res = await fetch(`${PAYLOAD_URL}/api/payment-methods?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getPaymentMethods: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: PayloadPaymentMethod[] } = await res.json();
const docs = Array.isArray(data.docs) ? data.docs : [];
return docs.map((doc) => ({
id: doc.id,
title: doc.title,
icons: (doc.icons ?? [])
.map((row) => (typeof row.icon === "object" && row.icon ? row.icon.url : null))
.filter((url): url is string => Boolean(url)),
}));
}
export type WerkzeugeCard = {
id: number;
title: string;
description: string;
icon: string;
ctaLabel: string;
ctaHref: string;
};
type PayloadWerkzeugeCard = {
id: number;
title: string;
description: string;
icon: { url: string } | number | null;
ctaLabel: string;
ctaHref: string;
};
export async function getWerkzeugeCards(): Promise<WerkzeugeCard[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
sort: "sortOrder",
depth: "1",
limit: "20",
});
const res = await fetch(`${PAYLOAD_URL}/api/werkzeuge-cards?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getWerkzeugeCards: Payload returned ${res.status} ${res.statusText}`);
return [];
}
const data: { docs?: PayloadWerkzeugeCard[] } = await res.json();
const docs = Array.isArray(data.docs) ? data.docs : [];
return docs.map((doc) => ({
id: doc.id,
title: doc.title,
description: doc.description,
icon: typeof doc.icon === "object" && doc.icon ? doc.icon.url : "",
ctaLabel: doc.ctaLabel,
ctaHref: doc.ctaHref,
}));
}
export type LegalPageType = "impressum" | "datenschutz" | "agb" | "widerruf";
export type LegalPage = {
type: LegalPageType;
title: string;
content: unknown;
attachment: { url: string; title: string } | null;
};
type PayloadLegalPage = {
type: LegalPageType;
title: string;
content: unknown;
attachment: { url: string; title: string } | number | null;
};
export async function getLegalPage(type: LegalPageType): Promise<LegalPage | null> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[type][equals]": type,
depth: "1",
limit: "1",
});
const res = await fetch(`${PAYLOAD_URL}/api/legal-pages?${params}`, {
next: { revalidate: 60 },
});
if (!res.ok) {
console.error(`getLegalPage: Payload returned ${res.status} ${res.statusText}`);
return null;
}
const data: { docs?: PayloadLegalPage[] } = await res.json();
const doc = data.docs?.[0];
if (!doc) return null;
return {
type: doc.type,
title: doc.title,
content: doc.content,
attachment:
typeof doc.attachment === "object" && doc.attachment
? { url: doc.attachment.url, title: doc.attachment.title }
: null,
};
}