Add schema.org structured data, Vorkasse email notice, newsletter duplicate detection

- Organization (site-wide), Product (/todo-cards), BlogPosting (every
  /blog/[slug]) JSON-LD via new app/lib/structuredData.ts — no new
  Payload fields needed, derived from existing data. Verified locally
  by curling each page and checking the rendered script tag.
- Order confirmation email gains the same "please transfer to this
  account, processed after payment received" notice the invoice PDF
  already had for Vorkasse orders — OrderConfirmationData's new
  isManualPayment flag is set explicitly by each caller (never derived
  from paymentMethodTitle, which already broke once this session after
  a payment-methods rename). CompanySettings gains bankName (existed on
  the backend, was missing from the frontend's type/usage).
- Newsletter signup now detects an already-subscribed email
  (verified empirically: Brevo's doubleOptinConfirmation endpoint gives
  identical 201 responses for new vs. already-confirmed contacts) via a
  GET /v3/contacts/{email} pre-check, and shows a distinct message
  instead of silently resending the confirmation mail. Success message
  text centralized in useNewsletterSignup.ts instead of duplicated
  across 4 forms.
- Bumped @einfach-produktiv/invoicing to the version with the
  unpaid-notice layout fix (full width, more top spacing — was
  squeezed into the narrow paid-badge column).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marco
2026-07-25 17:11:48 +00:00
parent 1dbc0c31ff
commit 1706da8598
17 changed files with 295 additions and 31 deletions
+102
View File
@@ -0,0 +1,102 @@
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).