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 { 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 { 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 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 { 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 { 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 { 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> { const uniqueIds = [...new Set(ids)]; const map = new Map(); 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 { 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 { 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 { 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 { 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 { 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"