Files
einfach-produktiv/app/lib/payload.ts
T
Marco ed58a65b8f Add blog overview page (/blog), link Navbar "Blog" to it
- app/blog/page.tsx: header (title/subheading + bleeding photo with a
  left-edge fade, per Figma node 4577:330), featured-post card (most
  recent), remaining posts as a divided list with category/readTime/date,
  and the reusable Newsletter signup panel. Uses all real posts from
  Payload — the Figma mockup padded its list out to 4 items with two
  posts that were never actually seeded, so this only renders what
  exists (currently 1 featured + 2 listed).
- lib/payload.ts: BlogPost now carries publishedAt (needed for the list's
  per-post date), removed the now-redundant duplicate field on PostDetail.
- Navbar: "Blog" now links to /blog instead of scrolling to the
  homepage's #blog anchor — same real-route pattern as "Shop".
- blog/[slug]/page.tsx: earlier title-size tuning (fluid clamp between
  text-h-feature and text-display) from this session's follow-up
  feedback, not yet pushed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 21:04:10 +00:00

204 lines
5.4 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;
};
type PayloadPost = {
id: number;
title: string;
slug: string;
category: { name: string } | number | null;
readTime: number;
excerpt: string;
thumbnail: { url: string } | number | null;
publishedAt: string;
};
export async function getBlogPosts(limit = 3): Promise<BlogPost[]> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
sort: "-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,
}));
}
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,
};
}
// 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;
image: string;
href: string | null;
};
type PayloadProduct = {
id: number;
name: string;
slug: string;
description: string | null;
price: number;
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,
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 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,
};
}