31e4f907f2
resolveAndTrackRedirect() now filters on urlPrefix in addition to code, with each route.ts passing its own literal prefix — previously the field was admin-display-only, so a code marked "/sticker" in Payload silently kept resolving under /r/<code> too. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01G5mssdCBir9kyXTmqBjV3h
1521 lines
65 KiB
TypeScript
1521 lines
65 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;
|
||
categories: string[];
|
||
readTime: number;
|
||
excerpt: string;
|
||
thumbnail: string | null;
|
||
publishedAt: string;
|
||
featured: boolean;
|
||
};
|
||
|
||
type PayloadPost = {
|
||
id: number;
|
||
title: string;
|
||
slug: string;
|
||
categories: ({ name: string } | number)[];
|
||
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,
|
||
// Draft/scheduled posts never appear publicly — same "filter, not
|
||
// access-control" pattern as Products.active. Live preview
|
||
// (LivePostContent.tsx) bypasses this entirely since it fetches the
|
||
// one specific document by id directly, not through this list.
|
||
"where[status][equals]": "published",
|
||
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,
|
||
categories: (post.categories ?? [])
|
||
.map((c) => (typeof c === "object" && c ? c.name : null))
|
||
.filter((name): name is string => Boolean(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;
|
||
/** SEO overrides (Posts.ts's "SEO" collapsible group) — each null when
|
||
* empty, callers fall back to title/excerpt/thumbnail themselves rather
|
||
* than baking the fallback in here, so the distinction between "no
|
||
* override set" and "override happens to equal the normal value" stays
|
||
* visible to whoever reads this. */
|
||
seoTitle: string | null;
|
||
seoDescription: string | null;
|
||
seoImage: string | null;
|
||
};
|
||
|
||
export type PayloadPostDetail = PayloadPost & {
|
||
content: unknown;
|
||
quoteLabel: string | null;
|
||
relatedProduct: PayloadProduct | null;
|
||
seoTitle?: string | null;
|
||
seoDescription?: string | null;
|
||
seoImage?: { url: string } | number | 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,
|
||
categories: (doc.categories ?? [])
|
||
.map((c) => (typeof c === "object" && c ? c.name : null))
|
||
.filter((name): name is string => Boolean(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,
|
||
seoTitle: doc.seoTitle || null,
|
||
seoDescription: doc.seoDescription || null,
|
||
seoImage: typeof doc.seoImage === "object" && doc.seoImage ? doc.seoImage.url : 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",
|
||
});
|
||
// Draft/scheduled posts 404 for a normal visitor — draftMode's preview
|
||
// (options.draft, wired from the page's own draftMode() call) is the
|
||
// one legitimate way to view one before its scheduledPublishAt fires.
|
||
if (!options?.draft) params.set("where[status][equals]", "published");
|
||
|
||
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;
|
||
// The raw Payload numeric id — `id` above is the slug (used everywhere
|
||
// as the "commerce id" — cart, checkout, URLs), but a few relationships
|
||
// (Orders.items.product, WishlistItems.product) are real Payload
|
||
// relationship fields storing this number instead. Kept alongside the
|
||
// slug rather than replacing it, to avoid touching every existing
|
||
// slug-based call site.
|
||
numericId: number;
|
||
name: string;
|
||
// Short tagline shown under the name on cards/cart/PDP hero — see
|
||
// Products.ts's own field comment. null/"" means show nothing extra.
|
||
subline: string | null;
|
||
// Raw Lexical richText JSON — rendered formatted (bold/italic/multiple
|
||
// paragraphs) on the PDP via the shared <RichText> component. Plain-text
|
||
// consumers (Passend-dazu cards, JSON-LD) use `descriptionText` below
|
||
// instead of re-deriving text from this themselves.
|
||
description: unknown | null;
|
||
descriptionText: string;
|
||
// Not shown anywhere in the UI today — only consumed by
|
||
// buildProductSchema (JSON-LD's Product.sku), a real Schema.org property
|
||
// Google's rich-result validator looks for.
|
||
sku: string | null;
|
||
price: number;
|
||
compareAtPrice: number | null;
|
||
image: string;
|
||
// Extra photos beyond `image` above, for ProductGallery.tsx's
|
||
// thumbnail-strip UI — empty for the vast majority of products (opt-in
|
||
// per product, see Products.ts's own field comment). Never includes
|
||
// `image` itself; ProductGallery treats `image` as always-slide-zero.
|
||
gallery: 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;
|
||
// Lexical richText JSON (switched from plain textarea 2026-08-25 so
|
||
// admins can bold parts of the teaser) — rendered by
|
||
// ProductSpotlight.tsx's own minimal inline converter, not the full
|
||
// article-oriented RichText.tsx (that one adds block-level heading/
|
||
// paragraph spacing meant for blog posts/legal pages, wrong fit for a
|
||
// single teaser paragraph).
|
||
spotlightText: unknown | null;
|
||
spotlightImage: string | null;
|
||
// Per-product opt-in for a wishlist heart on the homepage spotlight —
|
||
// independent of (in addition to) the global wishlistEnabled toggle,
|
||
// which still gates the feature site-wide regardless of this flag.
|
||
spotlightShowWishlist: boolean;
|
||
// Plain booleans, not the raw stock/threshold numbers — the public API
|
||
// has no reason to leak exact stock counts, callers only ever need
|
||
// "can this be bought right now". `outOfStock` on the product itself
|
||
// only matters for a product with no variants; a varianted product's
|
||
// buyability is entirely per-variant (see each variant's own flag).
|
||
outOfStock: boolean;
|
||
// Derived, like outOfStock — no raw stock count/threshold leaked, callers
|
||
// only ever need "should a low-stock hint show for this right now".
|
||
lowStock: boolean;
|
||
// Unlike outOfStock/lowStock, this DOES expose the real number — it's
|
||
// the cap the add-to-cart controls (AddToCartButton/AddToCartInlineButton,
|
||
// CartContent's quantity stepper) need client-side to stop a shopper from
|
||
// putting more in the cart than checkout would actually accept, instead
|
||
// of only finding out at the very last step (api/checkout/route.ts's own
|
||
// stock check, which stays as the authoritative server-side guard). null
|
||
// means "no cap" — backorder allowed or inventory not tracked.
|
||
maxQty: number | null;
|
||
// Per-product override — null means "use the tenant's default rate"
|
||
// (CompanySettings.taxRatePercent, fetched separately since it's behind
|
||
// an admin-only secret, see getCompanySettings()). Display-only on the
|
||
// storefront; the actual rate used for order totals is resolved and
|
||
// snapshotted server-side at checkout (api/checkout/route.ts).
|
||
taxRatePercent: number | null;
|
||
// No shipping cost for this product at all (e.g. a digital download) —
|
||
// never shows "zzgl. Versand" on its own product page, and doesn't count
|
||
// toward "does this cart need a shipping line" (lib/cartTotals.ts's
|
||
// cartHasShippableItem()). A cart with even one item that does NOT have
|
||
// this set still gets charged/shown the normal shipping cost — this only
|
||
// exempts the individual product, not the whole cart.
|
||
noShippingCost: boolean;
|
||
variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
|
||
// Empty for a product that predates this field or was never tagged —
|
||
// treated as "matches every category filter" by the shop grid rather
|
||
// than "matches none", since an uncategorized product shouldn't just
|
||
// disappear the moment a category filter is applied.
|
||
categories: string[];
|
||
// Optional cross-sell pick (Products.relatedProduct) — powers a "Passt
|
||
// dazu" card on the product's own detail page, same field/pattern as
|
||
// Posts.relatedProduct. null means no card. Only one level deep (this
|
||
// related product's own relatedProduct is never resolved/shown) — see
|
||
// mapPayloadProduct's own comment.
|
||
relatedProduct: Product | null;
|
||
// Optional PDP page-builder content — see Products.ts's own `layout`
|
||
// field comment. Empty for every existing hand-coded PDP (der-eine,
|
||
// todo-cards, tasse-die-pause); a product's own page.tsx decides whether
|
||
// to render this or its bespoke components, this type doesn't enforce
|
||
// either.
|
||
layout: ProductBlock[];
|
||
};
|
||
|
||
type PayloadProduct = {
|
||
id: number;
|
||
name: string;
|
||
subline: string | null;
|
||
slug: string;
|
||
layout: PayloadProductBlock[] | null;
|
||
description: unknown | null;
|
||
sku: string | null;
|
||
price: number;
|
||
compareAtPrice: number | null;
|
||
image: { url: string } | number | null;
|
||
gallery: ({ url: string } | number)[] | null;
|
||
detailHref: string | null;
|
||
active: boolean;
|
||
updatedAt: string;
|
||
spotlight: boolean;
|
||
spotlightEyebrow: string | null;
|
||
spotlightHeadline: string | null;
|
||
spotlightText: unknown | null;
|
||
spotlightImage: { url: string } | number | null;
|
||
spotlightShowWishlist: boolean;
|
||
trackInventory: boolean;
|
||
stock: number | null;
|
||
allowBackorder: boolean;
|
||
lowStockThreshold: number | null;
|
||
taxRatePercent: number | null;
|
||
noShippingCost: boolean;
|
||
variants:
|
||
| {
|
||
name: string;
|
||
priceOverride: number | null;
|
||
trackInventory: boolean;
|
||
stock: number | null;
|
||
allowBackorder: boolean;
|
||
lowStockThreshold: number | null;
|
||
}[]
|
||
| null;
|
||
categories: ({ name: string } | number)[] | null;
|
||
relatedProduct: PayloadProduct | number | null;
|
||
};
|
||
|
||
// A product/variant is only actually unbuyable when it opted into
|
||
// inventory tracking AND has zero stock AND backorders aren't allowed —
|
||
// the same three-condition check lib/inventory.ts's adjustStock() effectively
|
||
// mirrors from the other direction (it only ever touches stock when
|
||
// trackInventory is on in the first place).
|
||
function isOutOfStock(trackInventory: boolean, stock: number | null, allowBackorder: boolean): boolean {
|
||
return trackInventory && !allowBackorder && (stock ?? 0) <= 0;
|
||
}
|
||
|
||
// Below the threshold but not already out of stock — out-of-stock gets its
|
||
// own distinct "Ausverkauft" badge, a low-stock one on top of that would be
|
||
// redundant/contradictory.
|
||
function isLowStock(trackInventory: boolean, stock: number | null, threshold: number | null): boolean {
|
||
return trackInventory && threshold != null && stock != null && stock > 0 && stock <= threshold;
|
||
}
|
||
|
||
// null (no cap) whenever backorder is allowed or inventory isn't tracked —
|
||
// only a hard-tracked, non-backorderable stock count actually limits what a
|
||
// shopper can add to their cart.
|
||
function maxPurchasableQty(trackInventory: boolean, stock: number | null, allowBackorder: boolean): number | null {
|
||
return trackInventory && !allowBackorder ? (stock ?? 0) : null;
|
||
}
|
||
|
||
type LexicalTextNode = {
|
||
text?: string;
|
||
children?: LexicalTextNode[];
|
||
};
|
||
|
||
// Mirrors the backend's own Posts.ts extractPlainText (same shape, same
|
||
// space-joined fallback for multiple paragraphs/children) — kept as a
|
||
// separate copy rather than a shared package since it's a few lines and
|
||
// the two run in different runtimes (Payload server vs. this frontend).
|
||
function extractPlainText(node: LexicalTextNode | null | undefined): string {
|
||
if (!node) return "";
|
||
if (typeof node.text === "string") return node.text;
|
||
if (Array.isArray(node.children)) return node.children.map(extractPlainText).join(" ");
|
||
return "";
|
||
}
|
||
|
||
// 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 {
|
||
const descriptionRoot = (product.description as { root?: LexicalTextNode } | null)?.root;
|
||
return {
|
||
id: product.slug,
|
||
numericId: product.id,
|
||
name: product.name,
|
||
subline: product.subline || null,
|
||
description: product.description ?? null,
|
||
descriptionText: extractPlainText(descriptionRoot),
|
||
sku: product.sku || null,
|
||
price: product.price,
|
||
compareAtPrice: product.compareAtPrice ?? null,
|
||
image: typeof product.image === "object" && product.image ? product.image.url : "",
|
||
gallery: (product.gallery ?? [])
|
||
.map((g) => (typeof g === "object" && g ? g.url : null))
|
||
.filter((url): url is string => Boolean(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,
|
||
spotlightShowWishlist: product.spotlightShowWishlist,
|
||
outOfStock: isOutOfStock(product.trackInventory, product.stock, product.allowBackorder),
|
||
lowStock: isLowStock(product.trackInventory, product.stock, product.lowStockThreshold),
|
||
maxQty: maxPurchasableQty(product.trackInventory, product.stock, product.allowBackorder),
|
||
taxRatePercent: product.taxRatePercent ?? null,
|
||
noShippingCost: product.noShippingCost,
|
||
variants: (product.variants ?? []).map((v) => ({
|
||
name: v.name,
|
||
priceOverride: v.priceOverride,
|
||
outOfStock: isOutOfStock(v.trackInventory, v.stock, v.allowBackorder),
|
||
lowStock: isLowStock(v.trackInventory, v.stock, v.lowStockThreshold),
|
||
maxQty: maxPurchasableQty(v.trackInventory, v.stock, v.allowBackorder),
|
||
})),
|
||
categories: (product.categories ?? [])
|
||
.map((c) => (typeof c === "object" && c ? c.name : null))
|
||
.filter((name): name is string => Boolean(name)),
|
||
// Recursive call is safe here — depth=2 on the originating fetch means
|
||
// this nested object's OWN relatedProduct is never populated (stays a
|
||
// raw id or absent), so the recursion bottoms out after exactly one level.
|
||
relatedProduct: typeof product.relatedProduct === "object" && product.relatedProduct ? mapPayloadProduct(product.relatedProduct) : null,
|
||
layout: (product.layout ?? []).map(mapPayloadProductBlock),
|
||
};
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// For account order pages — Orders.items only snapshots a numeric
|
||
// `product` relationship id (see CustomerOrderItem in lib/customerAuth.ts),
|
||
// not an image URL, unlike the checkout/email/invoice paths that resolve
|
||
// the image once at order-creation/send time. depth=1 + a single `in`
|
||
// query is a plain product-id → image-url lookup, deliberately separate
|
||
// from getProducts()'s slug-keyed catalog (an order can reference a
|
||
// product that's since been deactivated/deleted, and slugs aren't even
|
||
// the key an order item stores).
|
||
// Powers /konto/merkliste — WishlistItems.product is a real numeric
|
||
// relationship (see Product.numericId's own comment), so displaying the
|
||
// wishlist needs a numeric-id lookup rather than getProducts()'s
|
||
// slug-keyed list.
|
||
export async function getProductsByIds(ids: number[]): Promise<Product[]> {
|
||
const uniqueIds = [...new Set(ids)];
|
||
if (uniqueIds.length === 0) return [];
|
||
const params = new URLSearchParams({ "where[id][in]": uniqueIds.join(","), depth: "2", limit: String(uniqueIds.length) });
|
||
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { next: { revalidate: 60 } });
|
||
if (!res.ok) {
|
||
console.error(`getProductsByIds: Payload returned ${res.status} ${res.statusText}`);
|
||
return [];
|
||
}
|
||
const data: { docs?: PayloadProduct[] } = await res.json();
|
||
return (data.docs ?? []).map(mapPayloadProduct);
|
||
}
|
||
|
||
export async function getProductImagesByIds(ids: number[]): Promise<Map<number, string>> {
|
||
const uniqueIds = [...new Set(ids)];
|
||
const map = new Map<number, string>();
|
||
if (uniqueIds.length === 0) return map;
|
||
const params = new URLSearchParams({ "where[id][in]": uniqueIds.join(","), depth: "1", limit: String(uniqueIds.length) });
|
||
const res = await fetch(`${PAYLOAD_URL}/api/products?${params}`, { next: { revalidate: 60 } });
|
||
if (!res.ok) return map;
|
||
const data: { docs?: { id: number; image: { url: string } | number | null }[] } = await res.json();
|
||
for (const doc of data.docs ?? []) {
|
||
if (typeof doc.image === "object" && doc.image) map.set(doc.id, doc.image.url);
|
||
}
|
||
return map;
|
||
}
|
||
|
||
// Resolves a bare media id to its download URL — used by
|
||
// /konto/bestellungen/[orderNumber] for order.dhlReturnLabelMedia, which
|
||
// stays a raw id on the order fetch itself (that fetch is deliberately
|
||
// depth=0, see getCustomerOrderDetail's own comment) rather than bumping
|
||
// that fetch's depth just for this one occasional field. `no-store`, not
|
||
// ISR-cached like getProductImagesByIds — a return label is a one-off,
|
||
// account-specific document, not shared/reusable content worth caching.
|
||
export async function getMediaUrlById(id: number): Promise<{ url: string; filename: string } | null> {
|
||
const res = await fetch(`${PAYLOAD_URL}/api/media/${id}`, { cache: "no-store" });
|
||
if (!res.ok) return null;
|
||
const data: { url?: string; filename?: string } = await res.json();
|
||
if (!data.url) return null;
|
||
return { url: data.url, filename: data.filename ?? "download.pdf" };
|
||
}
|
||
|
||
// 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,
|
||
}));
|
||
}
|
||
|
||
// Feeds /checkout's "Land" <select> (both the billing address and the
|
||
// optional shipping-address override) and its PLZ digit-count validation
|
||
// — previously a hardcoded array + PLZ_DIGITS map in CheckoutContent.tsx
|
||
// itself. Which countries are actually deliverable can now change without
|
||
// a code deploy (e.g. temporarily dropping Schweiz — no customs/export-
|
||
// invoice handling exists for it yet). Deliberately unrelated to VAT-
|
||
// exemption eligibility (lib/vatExemption.ts's isExemptionEligibleCountry(),
|
||
// still hardcoded to "Österreich") — that's a legal/tax-law question, not
|
||
// a shipping-logistics one, and stays in code on purpose.
|
||
export type ShippingCountry = {
|
||
name: string;
|
||
plzDigits: number;
|
||
};
|
||
|
||
type PayloadShippingCountry = ShippingCountry & { active: boolean };
|
||
|
||
export async function getShippingCountries(): Promise<ShippingCountry[]> {
|
||
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-countries?${params}`, {
|
||
next: { revalidate: 60 },
|
||
});
|
||
if (!res.ok) {
|
||
console.error(`getShippingCountries: Payload returned ${res.status} ${res.statusText}`);
|
||
return [];
|
||
}
|
||
|
||
const data: { docs?: PayloadShippingCountry[] } = await res.json();
|
||
const docs = Array.isArray(data.docs) ? data.docs : [];
|
||
return docs.map((doc) => ({
|
||
name: doc.name,
|
||
plzDigits: doc.plzDigits,
|
||
}));
|
||
}
|
||
|
||
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 },
|
||
};
|
||
}
|
||
|
||
// `provider` drives the checkout branch in app/api/checkout/route.ts —
|
||
// 'manual' (Überweisung) keeps today's immediate-order behavior, 'stripe'
|
||
// (Kreditkarte/PayPal) routes through the payment-intent/webhook-gated
|
||
// flow. Defaults to 'manual' below for any row created before this field
|
||
// existed, matching the Payload field's own default.
|
||
export type PaymentMethod = { id: number; title: string; icons: string[]; provider: "manual" | "stripe" };
|
||
|
||
type PayloadPaymentMethod = {
|
||
id: number;
|
||
title: string;
|
||
active: boolean;
|
||
icons: { icon: { url: string } | number | null }[];
|
||
provider?: "manual" | "stripe";
|
||
};
|
||
|
||
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)),
|
||
provider: doc.provider ?? "manual",
|
||
}));
|
||
}
|
||
|
||
export type CheckoutPaymentOption = PaymentMethod & { hint?: string };
|
||
|
||
// Kreditkarte and PayPal both resolve to `provider: 'stripe'` today, and
|
||
// both end up on the exact same Stripe PaymentIntent
|
||
// (`automatic_payment_methods: { enabled: true }` — Stripe's own
|
||
// recommended Payment Element pattern lets Stripe itself decide which
|
||
// eligible method to show, rather than the older per-method
|
||
// Checkout-Session split). Pre-selecting one of two identical-behind-the-
|
||
// scenes rows before the payment step is therefore no longer a real
|
||
// choice, just redundant friction — so this collapses every active
|
||
// `stripe` row into one "Online-Zahlung" option (representative id =
|
||
// the first such row's, since app/api/checkout/route.ts only branches on
|
||
// `provider`, never on which specific stripe row was picked) with a hint
|
||
// explaining that the actual instrument is chosen on the next screen.
|
||
// `manual` rows (Überweisung) pass through unchanged — one real gateway
|
||
// there, one option, nothing to collapse.
|
||
export function groupPaymentMethodsForCheckout(methods: PaymentMethod[]): CheckoutPaymentOption[] {
|
||
const stripeMethods = methods.filter((m) => m.provider === "stripe");
|
||
if (stripeMethods.length === 0) return methods;
|
||
|
||
const combinedIcons = Array.from(new Set(stripeMethods.flatMap((m) => m.icons)));
|
||
const online: CheckoutPaymentOption = {
|
||
id: stripeMethods[0].id,
|
||
title: "Online-Zahlung",
|
||
icons: combinedIcons,
|
||
provider: "stripe",
|
||
hint: "Kreditkarte, PayPal & weitere Methoden — die genaue Zahlungsart wählst du im nächsten Schritt.",
|
||
};
|
||
|
||
// Preserve `methods`' own order (already sortOrder-sorted by the fetch)
|
||
// instead of hardcoding manual-first — a real bug: "Online-Zahlung" had
|
||
// a lower sortOrder than "Überweisung (Vorkasse)" in the admin, but
|
||
// this function always put manual rows first regardless, so the
|
||
// checkout showed them in the wrong order. Splice the combined entry in
|
||
// at the position of the *first* stripe row encountered, drop any
|
||
// further stripe rows (already folded into `online`).
|
||
const result: CheckoutPaymentOption[] = [];
|
||
let onlineInserted = false;
|
||
for (const m of methods) {
|
||
if (m.provider === "stripe") {
|
||
if (!onlineInserted) {
|
||
result.push(online);
|
||
onlineInserted = true;
|
||
}
|
||
continue;
|
||
}
|
||
result.push(m);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
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" | "klarheits-check" | "der-eine" | "einfach-anfangen";
|
||
|
||
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, /7-tage-klarheits-check, and /der-eine (see TestimonialsGrid.tsx) —
|
||
// previously 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;
|
||
// Only populated for pages that need a dynamically-rendered section
|
||
// MID-document (currently just AGB's "Vertragspartner", sourced from
|
||
// company-settings — see VertragspartnerBlock.tsx). null everywhere
|
||
// else, including pages like Datenschutz whose one dynamic section sits
|
||
// at the very start and so can just be prepended instead of split.
|
||
contentPart2: unknown | null;
|
||
attachment: { url: string; title: string } | null;
|
||
updatedAt: string;
|
||
};
|
||
|
||
type PayloadLegalPage = {
|
||
type: LegalPageType;
|
||
title: string;
|
||
content: unknown;
|
||
contentPart2?: unknown;
|
||
attachment: { url: string; title: string } | number | null;
|
||
updatedAt: string;
|
||
};
|
||
|
||
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,
|
||
contentPart2: doc.contentPart2 ?? null,
|
||
attachment:
|
||
typeof doc.attachment === "object" && doc.attachment
|
||
? { url: doc.attachment.url, title: doc.attachment.title }
|
||
: null,
|
||
updatedAt: doc.updatedAt,
|
||
};
|
||
}
|
||
|
||
export type EmailTemplateType =
|
||
| "order-confirmation"
|
||
| "password-reset"
|
||
| "order-shipped"
|
||
| "order-cancelled"
|
||
| "order-return-requested"
|
||
| "order-returned"
|
||
| "order-tracking-added"
|
||
| "order-tracking-corrected"
|
||
| "order-delivered"
|
||
| "payment-method-switched"
|
||
| "back-in-stock";
|
||
|
||
type PayloadEmailTemplate = {
|
||
type: EmailTemplateType;
|
||
subject: string;
|
||
heading: string;
|
||
bodyText: string;
|
||
footerText: string | null;
|
||
// false means the admin deliberately suppressed this email — checked
|
||
// by orderEmail.ts before sending order-confirmation, never treated as
|
||
// "row missing, use hardcoded default" (that's what a null return from
|
||
// this function itself already means).
|
||
active: boolean;
|
||
};
|
||
|
||
// 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;
|
||
// Drives whether registerCourt/registerNumber/managingDirector/
|
||
// shareCapital are populated — mirrors Payload's CompanySettings.ts
|
||
// collection exactly (same option values), see buildLegalFooterLines()
|
||
// in emailTemplates.ts.
|
||
legalForm: "sole-proprietorship" | "e-k" | "gbr" | "ohg" | "kg" | "gmbh" | "ug" | "ag";
|
||
registerCourt: string | null;
|
||
registerNumber: string | null;
|
||
managingDirector: string | null;
|
||
// Stammkapital (GmbH/UG) / Grundkapital (AG) — optional, NOT a
|
||
// Pflichtangabe (only shown for legal forms that have this concept at
|
||
// all; see CompanySettings.ts's SHARE_CAPITAL_APPLICABLE_FORMS and its
|
||
// own comment on why this is voluntary, not required, disclosure).
|
||
shareCapital: number | null;
|
||
sellerStreet: string;
|
||
sellerZip: string;
|
||
sellerCity: string;
|
||
sellerCountry: string;
|
||
sellerEmail: string;
|
||
// Admin-editable override for outgoing mail's "Von"-Feld (company-settings
|
||
// "Adresse & Kontakt" tab, added 2026-07-29 alongside the SPF/SMTP-account
|
||
// switch to einfach-produktiv.com) — falls back to sellerName/sellerEmail
|
||
// when empty, see orderEmail.ts/alertAdmin.ts's own comments.
|
||
emailFromName: string | null;
|
||
emailFromAddress: string | null;
|
||
vatId: string;
|
||
taxRatePercent: number;
|
||
// Kleinunternehmerregelung (§19 UStG) — when true, checkout forces every
|
||
// order's items to 0% VAT (never de-grossed, unlike the intra-community
|
||
// exemption) and the tax rate above is ignored. Read live only at
|
||
// checkout time (see api/checkout/route.ts) to decide what to snapshot
|
||
// onto the new order — never read live when rendering an existing
|
||
// order's invoice, see OrderSnapshot/CustomerOrderDetail's own
|
||
// `kleinunternehmer` field for why.
|
||
kleinunternehmer: boolean;
|
||
iban: string | null;
|
||
bic: string | null;
|
||
bankName: 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;
|
||
}
|
||
|
||
// A separate, ISR-cached fetch (unlike getCompanySettings()'s deliberate
|
||
// cache: "no-store", where invoice generation needs always-fresh bank
|
||
// details/legal footer text) — the storefront's "inkl. X% MwSt." display
|
||
// rate only needs the same 60s freshness every other public catalog fetch
|
||
// here already has, and only ever needs the one number, not the seller's
|
||
// bank details/register info.
|
||
export async function getDefaultTaxRatePercent(): Promise<number> {
|
||
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 || "" },
|
||
next: { revalidate: 60 },
|
||
});
|
||
if (!res.ok) {
|
||
console.error(`getDefaultTaxRatePercent: Payload returned ${res.status} ${res.statusText}`);
|
||
return 19;
|
||
}
|
||
const data: { docs?: { taxRatePercent: number }[] } = await res.json();
|
||
return data.docs?.[0]?.taxRatePercent ?? 19;
|
||
}
|
||
|
||
// Same ISR-cached, public-catalog-freshness fetch as getDefaultTaxRatePercent()
|
||
// above (a separate round trip rather than reusing getCompanySettings()'s
|
||
// deliberate cache: "no-store") — powers the "inkl. X% MwSt." storefront
|
||
// hints (dropped entirely when this is true, see ProductGrid.tsx/
|
||
// ProductSpotlight.tsx/etc.) and the cart/checkout VAT-breakdown display.
|
||
export async function getKleinunternehmer(): Promise<boolean> {
|
||
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 || "" },
|
||
next: { revalidate: 60 },
|
||
});
|
||
if (!res.ok) {
|
||
console.error(`getKleinunternehmer: Payload returned ${res.status} ${res.statusText}`);
|
||
return false;
|
||
}
|
||
const data: { docs?: { kleinunternehmer: boolean }[] } = await res.json();
|
||
return data.docs?.[0]?.kleinunternehmer ?? false;
|
||
}
|
||
|
||
// Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer()
|
||
// above — gates the whole Wishlist feature (heart icon, /konto/merkliste,
|
||
// the Navbar link) site-wide. Deliberately off by default (see
|
||
// CompanySettings.ts's own field comment) so the feature stays entirely
|
||
// invisible in the frontend until a tenant actually wants it, rather than
|
||
// shipping a half-finished-looking icon everywhere.
|
||
export async function getWishlistEnabled(): Promise<boolean> {
|
||
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 || "" },
|
||
next: { revalidate: 60 },
|
||
});
|
||
if (!res.ok) {
|
||
console.error(`getWishlistEnabled: Payload returned ${res.status} ${res.statusText}`);
|
||
return false;
|
||
}
|
||
const data: { docs?: { wishlistEnabled?: boolean }[] } = await res.json();
|
||
return data.docs?.[0]?.wishlistEnabled ?? false;
|
||
}
|
||
|
||
// Same pattern as getWishlistEnabled() — gates the Navbar's search icon
|
||
// (SearchOverlay.tsx). Off by default so the feature stays invisible
|
||
// until a tenant explicitly wants it.
|
||
export async function getSearchEnabled(): Promise<boolean> {
|
||
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 || "" },
|
||
next: { revalidate: 60 },
|
||
});
|
||
if (!res.ok) {
|
||
console.error(`getSearchEnabled: Payload returned ${res.status} ${res.statusText}`);
|
||
return false;
|
||
}
|
||
const data: { docs?: { searchEnabled?: boolean }[] } = await res.json();
|
||
return data.docs?.[0]?.searchEnabled ?? false;
|
||
}
|
||
|
||
// Same pattern as getWishlistEnabled()/getSearchEnabled() — gates the
|
||
// shop overview's price-range filter (ProductGrid.tsx/PriceRangeFilter.tsx).
|
||
export async function getShopFilterEnabled(): Promise<boolean> {
|
||
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 || "" },
|
||
next: { revalidate: 60 },
|
||
});
|
||
if (!res.ok) {
|
||
console.error(`getShopFilterEnabled: Payload returned ${res.status} ${res.statusText}`);
|
||
return false;
|
||
}
|
||
const data: { docs?: { shopFilterEnabled?: boolean }[] } = await res.json();
|
||
return data.docs?.[0]?.shopFilterEnabled ?? false;
|
||
}
|
||
|
||
// Same pattern — gates the blog overview's category filter (blog/page.tsx).
|
||
export async function getBlogFilterEnabled(): Promise<boolean> {
|
||
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 || "" },
|
||
next: { revalidate: 60 },
|
||
});
|
||
if (!res.ok) {
|
||
console.error(`getBlogFilterEnabled: Payload returned ${res.status} ${res.statusText}`);
|
||
return false;
|
||
}
|
||
const data: { docs?: { blogFilterEnabled?: boolean }[] } = await res.json();
|
||
return data.docs?.[0]?.blogFilterEnabled ?? false;
|
||
}
|
||
|
||
// Same pattern — gates /konto/bestellungen's status/paymentStatus/year filters.
|
||
export async function getOrderFilterEnabled(): Promise<boolean> {
|
||
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 || "" },
|
||
next: { revalidate: 60 },
|
||
});
|
||
if (!res.ok) {
|
||
console.error(`getOrderFilterEnabled: Payload returned ${res.status} ${res.statusText}`);
|
||
return false;
|
||
}
|
||
const data: { docs?: { orderFilterEnabled?: boolean }[] } = await res.json();
|
||
return data.docs?.[0]?.orderFilterEnabled ?? false;
|
||
}
|
||
|
||
export type SeoSettings = {
|
||
defaultTitle: string | null;
|
||
titleTemplate: string | null;
|
||
defaultDescription: string | null;
|
||
defaultOgImage: string | null;
|
||
googleSearchConsoleVerification: string | null;
|
||
};
|
||
|
||
// Fallback matches the values hardcoded in app/layout.tsx before this field
|
||
// existed — used whenever the backend field is empty or unreachable, so
|
||
// filling in the CompanySettings SEO tab is optional, not a hard
|
||
// dependency for the site to render sensible metadata.
|
||
const SEO_SETTINGS_FALLBACK: SeoSettings = {
|
||
defaultTitle: "einfach produktiv. – Weil du auch noch ein Leben hast",
|
||
titleTemplate: "%s | einfach produktiv.",
|
||
defaultDescription:
|
||
"Kleine Impulse, praktische Werkzeuge und ehrliche Gedanken für mehr Klarheit im Alltag – weil du auch noch ein Leben hast.",
|
||
defaultOgImage: "/og-image.png",
|
||
googleSearchConsoleVerification: null,
|
||
};
|
||
|
||
// Same ISR-cached, public-catalog-freshness fetch as getKleinunternehmer()
|
||
// above — every page's metadata reads this, so it needs to be cheap/cached,
|
||
// not the always-fresh getCompanySettings() used for invoice generation.
|
||
export async function getSeoSettings(): Promise<SeoSettings> {
|
||
const params = new URLSearchParams({ "where[tenant.slug][equals]": TENANT_SLUG, limit: "1", depth: "1" });
|
||
const res = await fetch(`${PAYLOAD_URL}/api/company-settings?${params}`, {
|
||
headers: { "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "" },
|
||
next: { revalidate: 60 },
|
||
});
|
||
if (!res.ok) {
|
||
console.error(`getSeoSettings: Payload returned ${res.status} ${res.statusText}`);
|
||
return SEO_SETTINGS_FALLBACK;
|
||
}
|
||
const data: {
|
||
docs?: {
|
||
seoDefaultTitle?: string | null;
|
||
seoTitleTemplate?: string | null;
|
||
seoDefaultDescription?: string | null;
|
||
seoDefaultOgImage?: { url?: string } | number | null;
|
||
googleSearchConsoleVerification?: string | null;
|
||
}[];
|
||
} = await res.json();
|
||
const doc = data.docs?.[0];
|
||
if (!doc) return SEO_SETTINGS_FALLBACK;
|
||
return {
|
||
defaultTitle: doc.seoDefaultTitle || SEO_SETTINGS_FALLBACK.defaultTitle,
|
||
titleTemplate: doc.seoTitleTemplate || SEO_SETTINGS_FALLBACK.titleTemplate,
|
||
defaultDescription: doc.seoDefaultDescription || SEO_SETTINGS_FALLBACK.defaultDescription,
|
||
defaultOgImage:
|
||
(typeof doc.seoDefaultOgImage === "object" && doc.seoDefaultOgImage?.url) || SEO_SETTINGS_FALLBACK.defaultOgImage,
|
||
googleSearchConsoleVerification:
|
||
doc.googleSearchConsoleVerification || SEO_SETTINGS_FALLBACK.googleSearchConsoleVerification,
|
||
};
|
||
}
|
||
|
||
// Powers app/r/[code]/route.ts and app/sticker/[code]/route.ts — a static
|
||
// short link (e.g. printed on a QR code) that redirects to a `targetPath`
|
||
// editable in Payload at any time, so the QR code itself never needs
|
||
// reprinting. `cache: "no-store"` (unlike this file's other public-catalog
|
||
// fetches) since a stale hit here would send a visitor to a since-changed
|
||
// target, and the PATCH below needs the just-fetched id/clickCount, not a
|
||
// 60s-old ISR snapshot.
|
||
type PayloadRedirect = { id: number; targetPath: string; clickCount: number };
|
||
|
||
// `urlPrefix` filters the match to whichever fixed frontend route is
|
||
// actually calling this — Redirects.urlPrefix is otherwise just an admin
|
||
// label, not something either route enforced, so a code edited to
|
||
// urlPrefix "/sticker" in Payload would silently keep resolving under
|
||
// /r/<code> too without this. Each call site passes its own literal
|
||
// prefix (see route.ts files), so this actually makes the field mean
|
||
// something rather than only decorate the admin list.
|
||
export async function resolveAndTrackRedirect(code: string, urlPrefix: "/r" | "/sticker"): Promise<string | null> {
|
||
const params = new URLSearchParams({
|
||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||
"where[code][equals]": code,
|
||
"where[urlPrefix][equals]": urlPrefix,
|
||
"where[active][equals]": "true",
|
||
limit: "1",
|
||
});
|
||
|
||
const res = await fetch(`${PAYLOAD_URL}/api/redirects?${params}`, { cache: "no-store" });
|
||
if (!res.ok) {
|
||
console.error(`resolveAndTrackRedirect: Payload returned ${res.status} ${res.statusText}`);
|
||
return null;
|
||
}
|
||
|
||
const data: { docs?: PayloadRedirect[] } = await res.json();
|
||
const doc = data.docs?.[0];
|
||
if (!doc) return null;
|
||
|
||
fetch(`${PAYLOAD_URL}/api/redirects/${doc.id}`, {
|
||
method: "PATCH",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "",
|
||
},
|
||
body: JSON.stringify({ clickCount: doc.clickCount + 1, lastClickedAt: new Date().toISOString() }),
|
||
}).catch((err) => console.error("resolveAndTrackRedirect: click-tracking PATCH failed", err));
|
||
|
||
return doc.targetPath;
|
||
}
|
||
|
||
export type TrackingCode = {
|
||
id: number;
|
||
provider: "google-analytics" | "facebook-pixel" | "google-tag-manager" | "google-maps" | "other";
|
||
consentCategory: "necessary" | "analytics" | "marketing" | "content";
|
||
measurementId: string | null;
|
||
pixelId: string | null;
|
||
containerId: string | null;
|
||
customScript: string | null;
|
||
};
|
||
|
||
// Public read (TrackingCodes.ts's own access.read: () => true), unlike
|
||
// most of this file's other Company-Settings-adjacent fetchers — no
|
||
// x-order-service-secret header needed. Only `active: true` rows, since
|
||
// TrackingScripts.tsx has no reason to even know an inactive one exists.
|
||
// Consent-gating itself happens in TrackingScripts.tsx, not here — this
|
||
// fetcher runs server-side (no cookie access), the gating decision is a
|
||
// client-only concern (useConsent()).
|
||
export async function getTrackingCodes(): Promise<TrackingCode[]> {
|
||
const params = new URLSearchParams({
|
||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||
"where[active][equals]": "true",
|
||
depth: "0",
|
||
limit: "50",
|
||
});
|
||
const res = await fetch(`${PAYLOAD_URL}/api/tracking-codes?${params}`, {
|
||
next: { revalidate: 60 },
|
||
});
|
||
if (!res.ok) {
|
||
console.error(`getTrackingCodes: Payload returned ${res.status} ${res.statusText}`);
|
||
return [];
|
||
}
|
||
const data: { docs?: TrackingCode[] } = await res.json();
|
||
return Array.isArray(data.docs) ? data.docs : [];
|
||
}
|
||
|
||
// ── Products.layout (PDP page builder) ─────────────────────────────────────
|
||
// Shares 9 of PageBlock's own block types verbatim (richTextSection/
|
||
// stepRow/quote/image/icon/pillList/checklistImage/table/ctaCard — same
|
||
// Payload Block configs, reused a second time on Products.layout, see that
|
||
// field's own comment) plus two product-specific ones that don't exist on
|
||
// Pages: productHero (the one PDP hero variant for now — just a body
|
||
// richText, everything else about the hero comes from the product itself)
|
||
// and productPricingPanel (the closing buy panel, no fields of its own).
|
||
|
||
export type ProductBlock =
|
||
| { blockType: "productHero"; id: string; body: unknown | null }
|
||
| { blockType: "productPricingPanel"; id: string }
|
||
| { blockType: "richTextSection"; id: string; content: unknown }
|
||
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle: string | null; description: string }[] }
|
||
| { blockType: "quote"; id: string; text: string; label: string | null }
|
||
| { blockType: "image"; id: string; image: string | null; caption: string | null }
|
||
| { blockType: "icon"; id: string; icon: string }
|
||
| { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
|
||
| { blockType: "checklistImage"; id: string; image: string | null; items: { id: string; text: string }[] }
|
||
| { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
|
||
| { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
|
||
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description: string | null; href: string };
|
||
|
||
type PayloadProductBlock =
|
||
| { blockType: "productHero"; id: string; body?: unknown | null }
|
||
| { blockType: "productPricingPanel"; id: string }
|
||
| { blockType: "richTextSection"; id: string; content: unknown }
|
||
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle?: string | null; description: string }[] }
|
||
| { blockType: "quote"; id: string; text: string; label?: string | null }
|
||
| { blockType: "image"; id: string; image: PayloadPageImageField; caption?: string | null }
|
||
| { blockType: "icon"; id: string; icon: string }
|
||
| { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
|
||
| { blockType: "checklistImage"; id: string; image: PayloadPageImageField; items: { id: string; text: string }[] }
|
||
| { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
|
||
| { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
|
||
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description?: string | null; href: string };
|
||
|
||
function mapPayloadProductBlock(block: PayloadProductBlock): ProductBlock {
|
||
switch (block.blockType) {
|
||
case "productHero":
|
||
return { blockType: "productHero", id: block.id, body: block.body ?? null };
|
||
case "image":
|
||
return { blockType: "image", id: block.id, image: mapPayloadPageImage(block.image), caption: block.caption ?? null };
|
||
case "checklistImage":
|
||
return { blockType: "checklistImage", id: block.id, image: mapPayloadPageImage(block.image), items: block.items };
|
||
case "quote":
|
||
return { blockType: "quote", id: block.id, text: block.text, label: block.label ?? null };
|
||
case "ctaCard":
|
||
return { blockType: "ctaCard", id: block.id, eyebrow: block.eyebrow, title: block.title, description: block.description ?? null, href: block.href };
|
||
case "stepRow":
|
||
return {
|
||
blockType: "stepRow",
|
||
id: block.id,
|
||
items: block.items.map((item) => ({ ...item, subtitle: item.subtitle ?? null })),
|
||
};
|
||
default:
|
||
return block;
|
||
}
|
||
}
|
||
|
||
// ── Pages (generic slug-routed content pages / page builder) ──────────────
|
||
// Powers app/[slug]/page.tsx — the first generic catch-all route in this
|
||
// repo (every other content page today is its own static directory).
|
||
// `layout` is a Payload native `type: 'blocks'` field (docker/payload/src/
|
||
// collections/Pages.ts), a different shape from Posts.content's Lexical-
|
||
// embedded Blocks (see RichText.tsx's own comment) — each block here is a
|
||
// full page section, not an inline prose element.
|
||
|
||
export type PageBlock =
|
||
| { blockType: "hero"; id: string; headline: string; subline: string | null; image: string | null; ctaLabel: string | null; ctaHref: string | null }
|
||
| { blockType: "richTextSection"; id: string; content: unknown }
|
||
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle: string | null; description: string }[] }
|
||
| { blockType: "quote"; id: string; text: string; label: string | null }
|
||
| { blockType: "image"; id: string; image: string | null; caption: string | null }
|
||
| { blockType: "icon"; id: string; icon: string }
|
||
| { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
|
||
| { blockType: "checklistImage"; id: string; image: string | null; items: { id: string; text: string }[] }
|
||
| { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
|
||
| { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
|
||
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description: string | null; href: string };
|
||
|
||
export type Page = {
|
||
id: number;
|
||
title: string;
|
||
slug: string;
|
||
layout: PageBlock[];
|
||
seoTitle: string | null;
|
||
seoDescription: string | null;
|
||
seoImage: string | null;
|
||
};
|
||
|
||
type PayloadPageImageField = { url?: string | null } | number | null;
|
||
|
||
type PayloadPageBlock =
|
||
| { blockType: "hero"; id: string; headline: string; subline?: string | null; image?: PayloadPageImageField; ctaLabel?: string | null; ctaHref?: string | null }
|
||
| { blockType: "richTextSection"; id: string; content: unknown }
|
||
| { blockType: "stepRow"; id: string; items: { id: string; icon: string; title: string; subtitle?: string | null; description: string }[] }
|
||
| { blockType: "quote"; id: string; text: string; label?: string | null }
|
||
| { blockType: "image"; id: string; image: PayloadPageImageField; caption?: string | null }
|
||
| { blockType: "icon"; id: string; icon: string }
|
||
| { blockType: "pillList"; id: string; items: { id: string; label: string }[] }
|
||
| { blockType: "checklistImage"; id: string; image: PayloadPageImageField; items: { id: string; text: string }[] }
|
||
| { blockType: "table"; id: string; labelHeader: string; valueHeader: string; rows: { id: string; label: string; value: string }[] }
|
||
| { blockType: "testimonialsRef"; id: string; page: TestimonialsPage }
|
||
| { blockType: "ctaCard"; id: string; eyebrow: string; title: string; description?: string | null; href: string };
|
||
|
||
export type PayloadPage = {
|
||
id: number;
|
||
title: string;
|
||
slug: string;
|
||
layout: PayloadPageBlock[];
|
||
seoTitle?: string | null;
|
||
seoDescription?: string | null;
|
||
seoImage?: { url: string } | number | null;
|
||
};
|
||
|
||
function mapPayloadPageImage(ref: PayloadPageImageField): string | null {
|
||
return typeof ref === "object" && ref && typeof ref.url === "string" ? ref.url : null;
|
||
}
|
||
|
||
function mapPayloadPageBlock(block: PayloadPageBlock): PageBlock {
|
||
switch (block.blockType) {
|
||
case "image":
|
||
return { blockType: "image", id: block.id, image: mapPayloadPageImage(block.image), caption: block.caption ?? null };
|
||
case "checklistImage":
|
||
return { blockType: "checklistImage", id: block.id, image: mapPayloadPageImage(block.image), items: block.items };
|
||
case "quote":
|
||
return { blockType: "quote", id: block.id, text: block.text, label: block.label ?? null };
|
||
case "ctaCard":
|
||
return { blockType: "ctaCard", id: block.id, eyebrow: block.eyebrow, title: block.title, description: block.description ?? null, href: block.href };
|
||
case "stepRow":
|
||
return {
|
||
blockType: "stepRow",
|
||
id: block.id,
|
||
items: block.items.map((item) => ({ ...item, subtitle: item.subtitle ?? null })),
|
||
};
|
||
case "hero":
|
||
return {
|
||
blockType: "hero",
|
||
id: block.id,
|
||
headline: block.headline,
|
||
subline: block.subline ?? null,
|
||
image: mapPayloadPageImage(block.image ?? null),
|
||
ctaLabel: block.ctaLabel ?? null,
|
||
ctaHref: block.ctaHref ?? null,
|
||
};
|
||
default:
|
||
return block;
|
||
}
|
||
}
|
||
|
||
// Shared by getPageBySlug() and LivePageContent.tsx (re-maps the raw
|
||
// document useLivePreview receives via postMessage using this same logic)
|
||
// — same reuse pattern as mapPayloadPost.
|
||
export function mapPayloadPage(doc: PayloadPage): Page {
|
||
return {
|
||
id: doc.id,
|
||
title: doc.title,
|
||
slug: doc.slug,
|
||
layout: (doc.layout ?? []).map(mapPayloadPageBlock),
|
||
seoTitle: doc.seoTitle || null,
|
||
seoDescription: doc.seoDescription || null,
|
||
seoImage: typeof doc.seoImage === "object" && doc.seoImage ? doc.seoImage.url : null,
|
||
};
|
||
}
|
||
|
||
export async function getPageBySlug(slug: string, options?: { draft?: boolean }): Promise<Page | 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/pages?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
|
||
if (!res.ok) {
|
||
console.error(`getPageBySlug: Payload returned ${res.status} ${res.statusText}`);
|
||
return null;
|
||
}
|
||
|
||
const data: { docs?: PayloadPage[] } = await res.json();
|
||
const doc = data.docs?.[0];
|
||
if (!doc) return null;
|
||
return mapPayloadPage(doc);
|
||
}
|