819f87cdeb
Testimonial band: rebuilt as a proper flexbox two-column layout (photo column with an explicit width, quote in a flex-1 sibling) instead of an absolutely-positioned text block offset with percentage margin/padding — that measured against the row's full width and, combined with a max-w- on the text box, left almost no room for the actual text on wide viewports (wrapped to one word per line). Also dropped the muted-bg box behind the quote and widened the photo fade to match the actual mockup, which has no separate colored panel there at all. Hero: removed the separate big checkmark badge — the 4-step bar right above it already renders every step as a checkmark, so it was just repeating that. Added more top spacing to compensate. Dropped "Deine Bestellung macht sich jetzt auf den Weg zu dir." and added "inkl. MwSt." under Gesamtbetrag for consistency with /cart and /checkout. Wired the new Products.spotlightEyebrow field (CMS-editable "Neu im Shop" label) through lib/payload.ts into ProductSpotlight.tsx.
432 lines
12 KiB
TypeScript
432 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 & {
|
|
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 : "",
|
|
}));
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
}
|