e50d43ea44
Company data now has its own Payload admin group and a live in-browser PDF preview (react-pdf's PDFViewer) instead of just a plain settings form. Invoice header is a brand-colored rule instead of a filled band, and the footer is now pinned to the page bottom instead of following content flow.
677 lines
23 KiB
TypeScript
677 lines
23 KiB
TypeScript
import { formatPrice } from "./format";
|
||
|
||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||
const TENANT_SLUG = "einfach-produktiv";
|
||
|
||
// Used only by the fetchers backing Live Preview (posts, legal pages,
|
||
// testimonials) — callers pass `draft: true` only while Draft Mode is
|
||
// enabled (a document open in the Payload admin's Live Preview iframe),
|
||
// bypassing the normal 60s ISR cache there without affecting ordinary
|
||
// visitors. Deliberately does NOT call next/headers' draftMode() itself
|
||
// here — this module's mapping functions/types (mapPayloadTestimonial,
|
||
// mapPayloadPost, etc.) are also imported by "use client" components
|
||
// (LiveTestimonialsGrid.tsx, LivePostContent.tsx), and next/headers is a
|
||
// server-only import that breaks the client bundle the moment anything
|
||
// in this file touches it, even indirectly.
|
||
function livePreviewCacheOption(draft: boolean): { cache: "no-store" } | { next: { revalidate: 60 } } {
|
||
return draft ? { cache: "no-store" } : { next: { revalidate: 60 } };
|
||
}
|
||
|
||
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;
|
||
};
|
||
|
||
export type PayloadPostDetail = PayloadPost & {
|
||
content: unknown;
|
||
quoteLabel: string | null;
|
||
relatedProduct: PayloadProduct | null;
|
||
};
|
||
|
||
// Shared by getPostBySlug() and LivePostContent.tsx (which re-maps the raw
|
||
// document useLivePreview receives via postMessage using this same logic,
|
||
// instead of duplicating the field mapping a second time).
|
||
export function mapPayloadPost(doc: PayloadPostDetail): PostDetail {
|
||
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,
|
||
};
|
||
}
|
||
|
||
export async function getPostBySlug(slug: string, options?: { draft?: boolean }): 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}`, livePreviewCacheOption(Boolean(options?.draft)));
|
||
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 mapPayloadPost(doc);
|
||
}
|
||
|
||
// 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;
|
||
// `active` is opt-in for callers to filter by, not applied inside
|
||
// getProducts()/getProductBySlug() themselves — cart, checkout, order
|
||
// confirmation, and already-linked product detail pages (e.g.
|
||
// TodoKartenHero/Pricing calling getProductBySlug directly) all need to
|
||
// keep resolving a product regardless of its active status, unlike the
|
||
// shop grid / spotlight / related-products discovery surfaces, which
|
||
// filter `.filter(p => p.active)` themselves.
|
||
active: boolean;
|
||
updatedAt: string;
|
||
spotlight: boolean;
|
||
spotlightEyebrow: string | null;
|
||
spotlightHeadline: string | null;
|
||
spotlightText: string | null;
|
||
spotlightImage: 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;
|
||
active: boolean;
|
||
updatedAt: string;
|
||
spotlight: boolean;
|
||
spotlightEyebrow: string | null;
|
||
spotlightHeadline: string | null;
|
||
spotlightText: string | null;
|
||
spotlightImage: { url: string } | number | 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.
|
||
export 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,
|
||
active: product.active,
|
||
updatedAt: product.updatedAt,
|
||
spotlight: product.spotlight,
|
||
spotlightEyebrow: product.spotlightEyebrow || null,
|
||
spotlightHeadline: product.spotlightHeadline || null,
|
||
spotlightText: product.spotlightText || null,
|
||
spotlightImage:
|
||
typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : 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;
|
||
}
|
||
|
||
// Derived from getProducts() (same 60s-ISR-cached fetch every other
|
||
// discovery surface already uses) instead of its own separate Payload
|
||
// query — also what lets the auto-spotlight rule below just be a plain
|
||
// array check instead of a second round-trip.
|
||
//
|
||
// Auto-spotlight: with exactly 1 active product, that product IS the
|
||
// spotlight, full stop — overriding any `spotlight` flag set on some
|
||
// other (inactive) product. Confirmed product decision, not just a
|
||
// no-manual-flag fallback. Otherwise, same deterministic tie-break as
|
||
// before (most-recently-updated wins) among active products actually
|
||
// flagged `spotlight`.
|
||
export async function getSpotlightProduct(): Promise<Product | null> {
|
||
const products = await getProducts();
|
||
const active = products.filter((p) => p.active);
|
||
|
||
if (active.length === 1) return active[0];
|
||
|
||
const flagged = active.filter((p) => p.spotlight);
|
||
if (flagged.length === 0) return null;
|
||
return flagged.reduce((latest, p) => (p.updatedAt > latest.updatedAt ? p : latest));
|
||
}
|
||
|
||
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 (1–2 handling,
|
||
// 2–4 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 TestimonialsPage = "todo-cards" | "newsletter" | "challenge";
|
||
|
||
export type Testimonial = { id: number; quote: string; name: string; role: string; avatar: string };
|
||
|
||
export type PayloadTestimonial = {
|
||
id: number;
|
||
quote: string;
|
||
name: string;
|
||
role: string | null;
|
||
avatar: { url: string } | number | null;
|
||
};
|
||
|
||
// Shared by getTestimonials() and LiveTestimonialsGrid.tsx (re-maps the raw
|
||
// document useLivePreview receives via postMessage using this same logic).
|
||
export function mapPayloadTestimonial(doc: PayloadTestimonial): Testimonial {
|
||
return {
|
||
id: doc.id,
|
||
quote: doc.quote,
|
||
name: doc.name,
|
||
role: doc.role ?? "",
|
||
avatar: typeof doc.avatar === "object" && doc.avatar ? doc.avatar.url : "",
|
||
};
|
||
}
|
||
|
||
// Powers the identically-styled customer testimonial grids on /todo-cards,
|
||
// /newsletter, and /challenge (see TestimonialsGrid.tsx) — previously 3
|
||
// hardcoded arrays kept manually in sync. The single-quote "photo band"
|
||
// testimonials on /not-found and /bestellbestaetigung are a different
|
||
// shape (no avatar/role) and are not part of this collection.
|
||
export async function getTestimonials(page: TestimonialsPage, options?: { draft?: boolean }): Promise<Testimonial[]> {
|
||
const params = new URLSearchParams({
|
||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||
"where[page][equals]": page,
|
||
sort: "sortOrder",
|
||
depth: "1",
|
||
limit: "20",
|
||
});
|
||
|
||
const res = await fetch(`${PAYLOAD_URL}/api/testimonials?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
|
||
if (!res.ok) {
|
||
console.error(`getTestimonials: Payload returned ${res.status} ${res.statusText}`);
|
||
return [];
|
||
}
|
||
|
||
const data: { docs?: PayloadTestimonial[] } = await res.json();
|
||
const docs = Array.isArray(data.docs) ? data.docs : [];
|
||
return docs.map(mapPayloadTestimonial);
|
||
}
|
||
|
||
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, options?: { draft?: boolean }): 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}`, livePreviewCacheOption(Boolean(options?.draft)));
|
||
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,
|
||
};
|
||
}
|
||
|
||
export type EmailTemplateType =
|
||
| "order-confirmation"
|
||
| "password-reset"
|
||
| "order-shipped"
|
||
| "order-cancelled"
|
||
| "order-return-requested"
|
||
| "order-returned";
|
||
|
||
type PayloadEmailTemplate = {
|
||
type: EmailTemplateType;
|
||
subject: string;
|
||
heading: string;
|
||
bodyText: string;
|
||
footerText: string | null;
|
||
};
|
||
|
||
// draft:true is used by app/email-preview/[type]/page.tsx (Live Preview,
|
||
// see EmailTemplates.ts in the Payload repo); the real send (orderEmail.ts,
|
||
// Customers.ts's forgotPassword hook) always reads the published version.
|
||
export async function getEmailTemplate(
|
||
type: EmailTemplateType,
|
||
options?: { draft?: boolean },
|
||
): Promise<PayloadEmailTemplate | null> {
|
||
const params = new URLSearchParams({
|
||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||
"where[type][equals]": type,
|
||
limit: "1",
|
||
});
|
||
|
||
const res = await fetch(`${PAYLOAD_URL}/api/email-templates?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
|
||
if (!res.ok) {
|
||
console.error(`getEmailTemplate: Payload returned ${res.status} ${res.statusText}`);
|
||
return null;
|
||
}
|
||
|
||
const data: { docs?: PayloadEmailTemplate[] } = await res.json();
|
||
return data.docs?.[0] ?? null;
|
||
}
|
||
|
||
export type CompanySettings = {
|
||
sellerName: string;
|
||
sellerStreet: string;
|
||
sellerZip: string;
|
||
sellerCity: string;
|
||
sellerCountry: string;
|
||
sellerEmail: string;
|
||
vatId: string;
|
||
taxRatePercent: number;
|
||
bankDetails: string | null;
|
||
};
|
||
|
||
// Server-only in practice (only ever called from app/lib/invoiceData.ts),
|
||
// but kept in this file rather than a "use server"-only module since every
|
||
// other Payload fetcher lives here too — no client component imports it.
|
||
// company-settings' read access is admin-only plus this same service
|
||
// secret (see CompanySettings.ts) — it holds bank details, not something
|
||
// to leave publicly readable like Products/ShippingSettings.
|
||
export async function getCompanySettings(): Promise<CompanySettings | null> {
|
||
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1" });
|
||
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
|
||
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
|
||
cache: "no-store",
|
||
});
|
||
if (!res.ok) {
|
||
console.error(`getCompanySettings: Payload returned ${res.status} ${res.statusText}`);
|
||
return null;
|
||
}
|
||
const data: { docs?: CompanySettings[] } = await res.json();
|
||
return data.docs?.[0] ?? null;
|
||
}
|