Custom post content blocks (images/gallery/video/quote) + backend-driven SEO settings
RichText.tsx switched to Payload's official React renderer + custom JSXConverters (same call signature, LiveRichText/LivePostContent untouched) — needed to render the new Lexical Blocks the Payload repo's Posts.content just gained. Converters follow the existing CMS-image convention (relative + aspect-[...] + fill + object-cover); the video block resolves YouTube/Vimeo links to an iframe embed. New getSeoSettings() fetcher (same pattern as getKleinunternehmer()), app/layout.tsx now generateMetadata() reading it with the same fallback values it used to hardcode. Per-post SEO overrides (seoTitle/ seoDescription/seoImage) wired into the blog detail page's metadata, falling back to title/excerpt/thumbnail when empty. Also fixed while auditing every page's metadata: missing descriptions on 3 konto pages, a static title on the dynamic order-detail route, and missing OG images on /shop and /blog.
This commit is contained in:
@@ -93,12 +93,23 @@ export type PostDetail = BlogPost & {
|
||||
* 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
|
||||
@@ -120,6 +131,9 @@ export function mapPayloadPost(doc: PayloadPostDetail): PostDetail {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -870,3 +884,53 @@ export async function getKleinunternehmer(): Promise<boolean> {
|
||||
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. – Werkzeuge und Impulse für einen leichteren Alltag",
|
||||
titleTemplate: "%s | einfach produktiv.",
|
||||
defaultDescription: "Werkzeuge, Impulse und ein Blog für mehr Klarheit im Alltag.",
|
||||
defaultOgImage: 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;
|
||||
}[];
|
||||
} = 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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user