22fef4fe49
Mirrors the backend's sellerInfo.ts buildFromHeader() change — SMTP account moved to admin@einfach-produktiv.com, so From can finally point at sellerEmail itself instead of the fixed admin@mk360.de placeholder. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1011 lines
39 KiB
TypeScript
1011 lines
39 KiB
TypeScript
import { formatPrice } from "./format";
|
||
|
||
const PAYLOAD_URL = process.env.PAYLOAD_URL || "https://payload.mk360.de";
|
||
const TENANT_SLUG = "einfach-produktiv";
|
||
|
||
// Used only by the fetchers backing Live Preview (posts, legal pages,
|
||
// testimonials) — callers pass `draft: true` only while Draft Mode is
|
||
// enabled (a document open in the Payload admin's Live Preview iframe),
|
||
// bypassing the normal 60s ISR cache there without affecting ordinary
|
||
// visitors. Deliberately does NOT call next/headers' draftMode() itself
|
||
// here — this module's mapping functions/types (mapPayloadTestimonial,
|
||
// mapPayloadPost, etc.) are also imported by "use client" components
|
||
// (LiveTestimonialsGrid.tsx, LivePostContent.tsx), and next/headers is a
|
||
// server-only import that breaks the client bundle the moment anything
|
||
// in this file touches it, even indirectly.
|
||
function livePreviewCacheOption(draft: boolean): { cache: "no-store" } | { next: { revalidate: 60 } } {
|
||
return draft ? { cache: "no-store" } : { next: { revalidate: 60 } };
|
||
}
|
||
|
||
export type BlogPost = {
|
||
id: number;
|
||
title: string;
|
||
slug: string;
|
||
category: string;
|
||
readTime: number;
|
||
excerpt: string;
|
||
thumbnail: string | null;
|
||
publishedAt: string;
|
||
featured: boolean;
|
||
};
|
||
|
||
type PayloadPost = {
|
||
id: number;
|
||
title: string;
|
||
slug: string;
|
||
category: { name: string } | number | null;
|
||
readTime: number;
|
||
excerpt: string;
|
||
thumbnail: { url: string } | number | null;
|
||
publishedAt: string;
|
||
featured: boolean;
|
||
};
|
||
|
||
// sort: "-featured,-publishedAt" — Payload's multi-field sort puts any
|
||
// featured post(s) first, most-recently-published first among those, then
|
||
// the rest by date. Callers that just want posts[0] as "the" featured post
|
||
// (e.g. /blog) get a deterministic pick even if more than one post was
|
||
// accidentally marked featured — no special-casing needed here.
|
||
export async function getBlogPosts(limit = 3): Promise<BlogPost[]> {
|
||
const params = new URLSearchParams({
|
||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||
sort: "-featured,-publishedAt",
|
||
depth: "2",
|
||
limit: String(limit),
|
||
});
|
||
|
||
const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, {
|
||
next: { revalidate: 60 },
|
||
});
|
||
if (!res.ok) {
|
||
console.error(`getBlogPosts: Payload returned ${res.status} ${res.statusText}`);
|
||
return [];
|
||
}
|
||
|
||
const data: { docs?: PayloadPost[] } = await res.json();
|
||
const docs = Array.isArray(data.docs) ? data.docs : [];
|
||
return docs.map((post) => ({
|
||
id: post.id,
|
||
title: post.title,
|
||
slug: post.slug,
|
||
category:
|
||
typeof post.category === "object" && post.category
|
||
? post.category.name
|
||
: "",
|
||
readTime: post.readTime,
|
||
excerpt: post.excerpt,
|
||
thumbnail:
|
||
typeof post.thumbnail === "object" && post.thumbnail
|
||
? post.thumbnail.url
|
||
: null,
|
||
publishedAt: post.publishedAt,
|
||
featured: post.featured,
|
||
}));
|
||
}
|
||
|
||
export type PostDetail = BlogPost & {
|
||
content: unknown;
|
||
/** Label for every blockquote callout in this post's content (icon +
|
||
* underline included) — empty string hides that left side entirely,
|
||
* the blockquote itself still renders. See RichText.tsx's "quote" case. */
|
||
quoteLabel: string;
|
||
/** Powers the "Passend dazu" card at the end of the post — null hides
|
||
* the card entirely, per-post choice (unlike Products.spotlight, which
|
||
* is a single site-wide flag). */
|
||
relatedProduct: Product | null;
|
||
/** 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,
|
||
category:
|
||
typeof doc.category === "object" && doc.category ? doc.category.name : "",
|
||
readTime: doc.readTime,
|
||
excerpt: doc.excerpt,
|
||
thumbnail:
|
||
typeof doc.thumbnail === "object" && doc.thumbnail ? doc.thumbnail.url : null,
|
||
content: doc.content,
|
||
publishedAt: doc.publishedAt,
|
||
featured: doc.featured,
|
||
quoteLabel: doc.quoteLabel ?? "",
|
||
relatedProduct: doc.relatedProduct ? mapPayloadProduct(doc.relatedProduct) : null,
|
||
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",
|
||
});
|
||
|
||
const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
|
||
if (!res.ok) {
|
||
console.error(`getPostBySlug: Payload returned ${res.status} ${res.statusText}`);
|
||
return null;
|
||
}
|
||
|
||
const data: { docs?: PayloadPostDetail[] } = await res.json();
|
||
const doc = data.docs?.[0];
|
||
if (!doc) return null;
|
||
return mapPayloadPost(doc);
|
||
}
|
||
|
||
// Frontend-facing shape — `id` is Payload's `slug` field, not its numeric
|
||
// row id. Cart items are stored in localStorage keyed by this string (see
|
||
// lib/cart.ts), so slugs were chosen in the Products collection to match
|
||
// the ids the old hardcoded catalog used ("todo-karten" etc.) — switching
|
||
// to numeric ids here would silently orphan every existing shopper's cart.
|
||
export type Product = {
|
||
id: string;
|
||
name: string;
|
||
description: string;
|
||
price: number;
|
||
compareAtPrice: number | null;
|
||
image: string;
|
||
href: string | null;
|
||
// `active` is opt-in for callers to filter by, not applied inside
|
||
// getProducts()/getProductBySlug() themselves — cart, checkout, order
|
||
// confirmation, and already-linked product detail pages (e.g.
|
||
// TodoKartenHero/Pricing calling getProductBySlug directly) all need to
|
||
// keep resolving a product regardless of its active status, unlike the
|
||
// shop grid / spotlight / related-products discovery surfaces, which
|
||
// filter `.filter(p => p.active)` themselves.
|
||
active: boolean;
|
||
updatedAt: string;
|
||
spotlight: boolean;
|
||
spotlightEyebrow: string | null;
|
||
spotlightHeadline: string | null;
|
||
spotlightText: string | null;
|
||
spotlightImage: string | null;
|
||
// 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;
|
||
variants: { name: string; priceOverride: number | null; outOfStock: boolean; lowStock: boolean; maxQty: number | null }[];
|
||
};
|
||
|
||
type PayloadProduct = {
|
||
id: number;
|
||
name: string;
|
||
slug: string;
|
||
description: string | null;
|
||
price: number;
|
||
compareAtPrice: number | null;
|
||
image: { url: string } | number | null;
|
||
detailHref: string | null;
|
||
active: boolean;
|
||
updatedAt: string;
|
||
spotlight: boolean;
|
||
spotlightEyebrow: string | null;
|
||
spotlightHeadline: string | null;
|
||
spotlightText: string | null;
|
||
spotlightImage: { url: string } | number | null;
|
||
trackInventory: boolean;
|
||
stock: number | null;
|
||
allowBackorder: boolean;
|
||
lowStockThreshold: number | null;
|
||
taxRatePercent: number | null;
|
||
variants:
|
||
| {
|
||
name: string;
|
||
priceOverride: number | null;
|
||
trackInventory: boolean;
|
||
stock: number | null;
|
||
allowBackorder: boolean;
|
||
lowStockThreshold: number | null;
|
||
}[]
|
||
| 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,
|
||
name: product.name,
|
||
description: product.description ?? "",
|
||
price: product.price,
|
||
compareAtPrice: product.compareAtPrice ?? null,
|
||
image: typeof product.image === "object" && product.image ? product.image.url : "",
|
||
href: product.detailHref || null,
|
||
active: product.active,
|
||
updatedAt: product.updatedAt,
|
||
spotlight: product.spotlight,
|
||
spotlightEyebrow: product.spotlightEyebrow || null,
|
||
spotlightHeadline: product.spotlightHeadline || null,
|
||
spotlightText: product.spotlightText || null,
|
||
spotlightImage:
|
||
typeof product.spotlightImage === "object" && product.spotlightImage ? product.spotlightImage.url : null,
|
||
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,
|
||
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),
|
||
})),
|
||
};
|
||
}
|
||
|
||
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).
|
||
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;
|
||
}
|
||
|
||
// 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" | "challenge";
|
||
|
||
export type Testimonial = { id: number; quote: string; name: string; role: string; avatar: string };
|
||
|
||
export type PayloadTestimonial = {
|
||
id: number;
|
||
quote: string;
|
||
name: string;
|
||
role: string | null;
|
||
avatar: { url: string } | number | null;
|
||
};
|
||
|
||
// Shared by getTestimonials() and LiveTestimonialsGrid.tsx (re-maps the raw
|
||
// document useLivePreview receives via postMessage using this same logic).
|
||
export function mapPayloadTestimonial(doc: PayloadTestimonial): Testimonial {
|
||
return {
|
||
id: doc.id,
|
||
quote: doc.quote,
|
||
name: doc.name,
|
||
role: doc.role ?? "",
|
||
avatar: typeof doc.avatar === "object" && doc.avatar ? doc.avatar.url : "",
|
||
};
|
||
}
|
||
|
||
// Powers the identically-styled customer testimonial grids on /todo-cards,
|
||
// /newsletter, and /challenge (see TestimonialsGrid.tsx) — previously 3
|
||
// hardcoded arrays kept manually in sync. The single-quote "photo band"
|
||
// testimonials on /not-found and /bestellbestaetigung are a different
|
||
// shape (no avatar/role) and are not part of this collection.
|
||
export async function getTestimonials(page: TestimonialsPage, options?: { draft?: boolean }): Promise<Testimonial[]> {
|
||
const params = new URLSearchParams({
|
||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||
"where[page][equals]": page,
|
||
sort: "sortOrder",
|
||
depth: "1",
|
||
limit: "20",
|
||
});
|
||
|
||
const res = await fetch(`${PAYLOAD_URL}/api/testimonials?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
|
||
if (!res.ok) {
|
||
console.error(`getTestimonials: Payload returned ${res.status} ${res.statusText}`);
|
||
return [];
|
||
}
|
||
|
||
const data: { docs?: PayloadTestimonial[] } = await res.json();
|
||
const docs = Array.isArray(data.docs) ? data.docs : [];
|
||
return docs.map(mapPayloadTestimonial);
|
||
}
|
||
|
||
export type LegalPageType = "impressum" | "datenschutz" | "agb" | "widerruf";
|
||
|
||
export type LegalPage = {
|
||
type: LegalPageType;
|
||
title: string;
|
||
content: unknown;
|
||
attachment: { url: string; title: string } | null;
|
||
};
|
||
|
||
type PayloadLegalPage = {
|
||
type: LegalPageType;
|
||
title: string;
|
||
content: unknown;
|
||
attachment: { url: string; title: string } | number | null;
|
||
};
|
||
|
||
export async function getLegalPage(type: LegalPageType, options?: { draft?: boolean }): Promise<LegalPage | null> {
|
||
const params = new URLSearchParams({
|
||
"where[tenant.slug][equals]": TENANT_SLUG,
|
||
"where[type][equals]": type,
|
||
depth: "1",
|
||
limit: "1",
|
||
});
|
||
|
||
const res = await fetch(`${PAYLOAD_URL}/api/legal-pages?${params}`, livePreviewCacheOption(Boolean(options?.draft)));
|
||
if (!res.ok) {
|
||
console.error(`getLegalPage: Payload returned ${res.status} ${res.statusText}`);
|
||
return null;
|
||
}
|
||
|
||
const data: { docs?: PayloadLegalPage[] } = await res.json();
|
||
const doc = data.docs?.[0];
|
||
if (!doc) return null;
|
||
return {
|
||
type: doc.type,
|
||
title: doc.title,
|
||
content: doc.content,
|
||
attachment:
|
||
typeof doc.attachment === "object" && doc.attachment
|
||
? { url: doc.attachment.url, title: doc.attachment.title }
|
||
: null,
|
||
};
|
||
}
|
||
|
||
export type EmailTemplateType =
|
||
| "order-confirmation"
|
||
| "password-reset"
|
||
| "order-shipped"
|
||
| "order-cancelled"
|
||
| "order-return-requested"
|
||
| "order-returned"
|
||
| "order-tracking-added"
|
||
| "order-tracking-corrected"
|
||
| "order-delivered";
|
||
|
||
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;
|
||
}
|
||
|
||
export type SeoSettings = {
|
||
defaultTitle: string | null;
|
||
titleTemplate: string | null;
|
||
defaultDescription: string | null;
|
||
defaultOgImage: 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",
|
||
};
|
||
|
||
// 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;
|
||
}[];
|
||
} = 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,
|
||
};
|
||
}
|