d472eb546f
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>
429 lines
12 KiB
TypeScript
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,
|
|
};
|
|
}
|