Files
einfach-produktiv/app/lib/payload.ts
T

1412 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
description: string;
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;
};
type PayloadProduct = {
id: number;
name: string;
slug: string;
description: 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;
}
// Shared by getProducts() and getPostBySlug()'s relatedProduct — kept in
// one place instead of duplicating the same field mapping, which is
// exactly the kind of drift this session's Shipping Settings work was
// about eliminating elsewhere.
export function mapPayloadProduct(product: PayloadProduct): Product {
return {
id: product.slug,
numericId: product.id,
name: product.name,
description: product.description ?? "",
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,
};
}
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 (12 handling,
// 24 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";
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 — 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 };
// PATCH failure only logs — click tracking is informational, never worth
// stranding a visitor on a broken link over.
export async function resolveAndTrackRedirect(code: string): Promise<string | null> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[code][equals]": code,
"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 : [];
}
// ── 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);
}