885389b300
Pages/getPageBySlug/mapPayloadPage in payload.ts follow the exact getPostBySlug/mapPayloadPost pattern. PageBlocks.tsx renders a Pages doc's `layout` field, reusing existing components (RichText, StepArrow, STEP_ICONS, TestimonialsGrid) rather than reinventing per- block styling — matches what /lebensuhr, /3x3-system, and /7-tage-klarheits-check already hand-built. LivePageContent.tsx mirrors LivePostContent.tsx's live-preview pattern, using a synchronous subset of the block renderer (testimonialsRef needs an async fetch a client component can't perform inline, so it's skipped in preview only — same "editable subset" scope-cut LivePostContent.tsx already makes). buildWebPageSchema in structuredData.ts is the generic JSON-LD fallback for content types with no bespoke schema (Article/ Product don't fit a page-builder page). Verified end-to-end: inserted a real Pages test document (SQL, since no admin auth available here) covering richTextSection/quote/ctaCard, confirmed /[slug] renders it correctly, confirmed notFound() after deleting it, then cleaned up the test row.
123 lines
5.0 KiB
TypeScript
123 lines
5.0 KiB
TypeScript
import type { CompanySettings, Product } from "./payload";
|
|
|
|
// Pure JSON-LD builders — schema.org structured data for Google rich
|
|
// snippets (business info, product rich results, article cards). No
|
|
// component/rendering logic here; callers render the result via
|
|
// `<script type="application/ld+json">`. Kept separate from
|
|
// emailTemplates.ts/invoiceData.ts's own seller-formatting logic since
|
|
// schema.org's shape requirements are different from what an email/PDF
|
|
// needs (e.g. a `PostalAddress` object, not formatted address lines).
|
|
|
|
const SITE_URL = "https://einfach-produktiv.mk360.de";
|
|
|
|
// One Organization node reused as `publisher`/`seller` wherever those
|
|
// are needed (Article, Product) — schema.org allows (and Google prefers)
|
|
// linking back to a single canonical Organization via @id rather than
|
|
// repeating the full object on every page.
|
|
export function buildOrganizationSchema(seller: CompanySettings | null): Record<string, unknown> {
|
|
if (!seller) {
|
|
// Minimal fallback — still valid Organization markup even if
|
|
// company-settings is unreachable, better than emitting nothing at
|
|
// all (a page load shouldn't fail over structured data).
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "Organization",
|
|
"@id": `${SITE_URL}/#organization`,
|
|
name: "einfach produktiv.",
|
|
url: SITE_URL,
|
|
};
|
|
}
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "Organization",
|
|
"@id": `${SITE_URL}/#organization`,
|
|
name: seller.sellerName,
|
|
url: SITE_URL,
|
|
email: seller.sellerEmail,
|
|
address: {
|
|
"@type": "PostalAddress",
|
|
streetAddress: seller.sellerStreet,
|
|
postalCode: seller.sellerZip,
|
|
addressLocality: seller.sellerCity,
|
|
addressCountry: seller.sellerCountry === "Deutschland" ? "DE" : seller.sellerCountry,
|
|
},
|
|
// vatID is a real schema.org Organization property (distinct from
|
|
// taxID) — only included when set, same "omit rather than print an
|
|
// empty value" convention as buildLegalFooterLines() elsewhere.
|
|
...(seller.vatId ? { vatID: seller.vatId } : {}),
|
|
};
|
|
}
|
|
|
|
export function buildProductSchema(product: Product, url: string, seller: CompanySettings | null): Record<string, unknown> {
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "Product",
|
|
name: product.name,
|
|
description: product.description,
|
|
image: product.image,
|
|
url,
|
|
// No reviews/ratings system exists yet — `aggregateRating` is
|
|
// optional in the spec and deliberately omitted rather than faked;
|
|
// add it here once real reviews exist, not before.
|
|
offers: {
|
|
"@type": "Offer",
|
|
url,
|
|
priceCurrency: "EUR",
|
|
price: product.price.toFixed(2),
|
|
availability: product.outOfStock
|
|
? "https://schema.org/OutOfStock"
|
|
: "https://schema.org/InStock",
|
|
seller: { "@id": `${SITE_URL}/#organization` },
|
|
},
|
|
...(seller ? { brand: { "@type": "Brand", name: seller.sellerName } } : {}),
|
|
};
|
|
}
|
|
|
|
export function buildArticleSchema(
|
|
post: { title: string; excerpt: string; thumbnail: string | null; publishedAt: string; slug: string },
|
|
seller: CompanySettings | null,
|
|
): Record<string, unknown> {
|
|
const url = `${SITE_URL}/blog/${post.slug}`;
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "BlogPosting",
|
|
headline: post.title,
|
|
description: post.excerpt,
|
|
url,
|
|
mainEntityOfPage: url,
|
|
datePublished: post.publishedAt,
|
|
...(post.thumbnail ? { image: post.thumbnail } : {}),
|
|
// Single-author blog with no author field on Posts (see Posts.ts) —
|
|
// "Björn" is already hardcoded in the page's own author-bio block,
|
|
// matched here rather than left out entirely.
|
|
author: { "@type": "Person", name: "Björn" },
|
|
publisher: seller ? { "@id": `${SITE_URL}/#organization` } : { "@type": "Organization", name: "einfach produktiv." },
|
|
};
|
|
}
|
|
|
|
// Renders as a plain object, not a component — callers do
|
|
// `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />`
|
|
// directly (no need for a shared component around one line of JSX, and
|
|
// keeps this file free of "use client"/React concerns so server
|
|
// components can import it without issue).
|
|
|
|
// Generic fallback for content pages with no bespoke schema type of their
|
|
// own (Article for blog posts, Product for PDPs) — the Payload Pages
|
|
// collection (page builder) serves arbitrary content types, so a generic
|
|
// WebPage node is the right shape rather than guessing at a more specific
|
|
// one from the blocks it happens to contain.
|
|
export function buildWebPageSchema(
|
|
page: { title: string; seoDescription: string | null; slug: string },
|
|
seller: CompanySettings | null,
|
|
): Record<string, unknown> {
|
|
const url = `${SITE_URL}/${page.slug}`;
|
|
return {
|
|
"@context": "https://schema.org",
|
|
"@type": "WebPage",
|
|
name: page.title,
|
|
url,
|
|
...(page.seoDescription ? { description: page.seoDescription } : {}),
|
|
isPartOf: seller ? { "@id": `${SITE_URL}/#organization` } : { "@type": "Organization", name: "einfach produktiv." },
|
|
};
|
|
}
|