Files
einfach-produktiv/app/lib/payload.ts
T
Marco d472eb546f Move trust badges, shipping/payment methods, Werkzeuge cards, product spotlight into CMS
New Payload collections, all editable without a code deploy:
- TrustBadges: the horizontal Schneller-Versand/Versandkostenfrei/Mit-
  Liebe-verpackt row (TrustRow.tsx, now an async server component).
- CartTrustBadges: the Sichere-Zahlung/14-Tage-Rückgaberecht/Nachhaltig-
  verpackt sidebar bullets — shared by /cart (title only) and /checkout
  (title + description), which previously had two different hardcoded
  bullet lists for what's conceptually the same content.
- ShippingMethods: /checkout's Versandart radios. Each method has its own
  optional freeShippingThreshold — omitted means "never free" (Express),
  not "always free". /cart's FreeShippingBanner now targets the lowest
  threshold among active methods instead of a single global constant, and
  hides entirely if no active method has one.
- PaymentMethods: /checkout's Zahlungsart radios, icons as an array
  (Kreditkarte shows 3 logos, PayPal/Überweisung show 1).
- Products: new spotlight/spotlightHeadline/spotlightText/spotlightImage/
  compareAtPrice fields. ProductSpotlight.tsx (homepage) now shows
  whichever product has `spotlight` checked instead of being hardcoded to
  ToDo-Karten, with its own marketing copy separate from the plain
  catalog name/description. AddToCartButton takes an explicit productId
  prop now instead of a hardcoded "todo-karten" constant.
- WerkzeugeCards: the homepage's "Meine Werkzeuge" 3-card grid (Tools.tsx,
  now async). Icons use a uniform box instead of the previous per-card
  hand-tuned width/height/rotation, which only worked for 3 known,
  upside-down-authored SVGs — those were re-exported as pre-flipped PNGs.

lib/payload.ts gained getTrustBadges/getCartTrustBadges/getShippingMethods/
getPaymentMethods/getWerkzeugeCards/getSpotlightProduct, all with the same
graceful-empty-array-on-fetch-failure pattern as the existing functions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 23:15:46 +00:00

429 lines
12 KiB
TypeScript

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;
};
type PayloadPostDetail = PayloadPost & { content: unknown };
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,
};
}
// 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;
};
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((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,
}));
}
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 & {
spotlightHeadline: string | null;
spotlightText: string | null;
spotlightImage: string | null;
};
type PayloadSpotlightProduct = PayloadProduct & {
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,
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 : "",
}));
}
// Powers TrustRow.tsx — the horizontal "Schneller Versand /
// Versandkostenfrei / Mit Liebe verpackt" row.
export async function getTrustBadges(): Promise<TrustBadge[]> {
return fetchTrustBadgeList("trust-badges");
}
// 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 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,
};
}