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:
@@ -511,6 +511,24 @@ catalog, since an order only ever snapshots a numeric product id).
|
||||
touched spots and why some read a live setting and others a persisted
|
||||
per-order snapshot.
|
||||
|
||||
**Vorkasse instruction in the confirmation email (2026-07-25)** — the
|
||||
invoice PDF already showed this (see `@einfach-produktiv/invoicing`'s own
|
||||
`unpaidNoticeText`), but a customer often only glances at the email body
|
||||
itself, not the attached PDF. `OrderConfirmationData` gained a required
|
||||
`isManualPayment: boolean` field — **explicitly set by each caller**
|
||||
(checkout route's manual branch: `true`; the Stripe webhook path:
|
||||
always `false`, since only a *paid* Stripe order ever reaches that send at
|
||||
all), deliberately **not** derived from `paymentMethodTitle` inside
|
||||
`emailTemplates.ts` itself — that string ("Online-Zahlung", "Kreditkarte",
|
||||
"Überweisung (Vorkasse)", ...) is exactly the kind of thing a
|
||||
payment-methods rename already broke once this session (see
|
||||
`isPaidImmediately()` in the invoicing package). When `isManualPayment` is
|
||||
true, `vorkasseNotice()` renders a full-width block (own row, `margin-top:
|
||||
20px` — not squeezed into the Gesamtsumme table) naming the bank details
|
||||
(`CompanySettings.bankName`/`iban`/`bic`, now also exposed on the
|
||||
frontend's own `CompanySettings` type — previously only `iban`/`bic` were)
|
||||
and the "processed within 1–2 business days after payment received" note.
|
||||
|
||||
### Checkout state persistence
|
||||
|
||||
`app/lib/checkoutDraft.ts` — `localStorage` under `ep_checkout_draft`,
|
||||
@@ -1148,12 +1166,30 @@ unsynced.
|
||||
heading, thin brand divider). No query params to read — Brevo's
|
||||
redirect carries nothing this page needs, unlike `/checkout/verarbeitung`
|
||||
which polls actual payment status.
|
||||
- **Already-subscribed detection (2026-07-25)** — `doubleOptinConfirmation`
|
||||
itself gives no way to tell a brand-new signup apart from an
|
||||
already-confirmed contact re-submitting the form (verified directly:
|
||||
calling it twice for the same confirmed contact returns the identical
|
||||
`201` both times, just silently resends the confirmation mail). So
|
||||
`upsertNewsletterContact()` checks first via `GET /v3/contacts/{email}`
|
||||
— a contact's `listIds` on Brevo is only populated once double opt-in
|
||||
actually confirms, never for a merely-requested one, so its presence is
|
||||
a reliable signal. If already subscribed: skips the resend entirely and
|
||||
returns `{ ok: true, alreadySubscribed: true }` instead, which the UI
|
||||
(`useNewsletterSignup.ts`) surfaces as a different message ("Diese
|
||||
E-Mail-Adresse ist schon für unseren Newsletter angemeldet.") instead of
|
||||
the normal "check your inbox" copy. The check fails open (any error →
|
||||
proceed to the normal signup flow) — it's a UX nicety, never a reason to
|
||||
block a real signup.
|
||||
- **`app/lib/useNewsletterSignup.ts`** — the shared email/consent/submit
|
||||
state + on-blur validation + refocus-on-invalid-submit behind all four
|
||||
forms (same "state of the art, simple" input-quality bar as checkout's
|
||||
own fields). Each form keeps its own markup/visual style (`Newsletter`'s
|
||||
panel layout, `/challenge`'s hardcoded-hex-color palette, etc.) — only
|
||||
the logic is shared, not a one-size-fits-all component.
|
||||
the logic is shared, not a one-size-fits-all component. The success
|
||||
message text itself also now lives here (`successMessage`), not
|
||||
hardcoded 4 times per form — one of two variants depending on
|
||||
`alreadySubscribed`.
|
||||
- **`app/lib/email.ts`** — `isValidEmail()`/`validateEmailFormat()`,
|
||||
the single plain-email-format check shared by every newsletter form
|
||||
*and* checkout's own email field (previously duplicated between
|
||||
@@ -1724,6 +1760,44 @@ breakpoint no longer needed to be as conservative as originally set:
|
||||
768px. Both pushed from `md:flex-row` to `lg:flex-row` — stacked through
|
||||
the whole Tablet range, side by side again once there's real room.
|
||||
|
||||
## Structured data (schema.org, 2026-07-25)
|
||||
|
||||
JSON-LD (`<script type="application/ld+json">`) on the pages Google
|
||||
actually gives rich results for — built via pure functions in
|
||||
`app/lib/structuredData.ts`, no new Payload fields needed, everything
|
||||
derived from data that already exists.
|
||||
|
||||
- **`Organization`** — rendered once, site-wide, in `app/layout.tsx` (via
|
||||
`getCompanySettings()`, the same established pattern `/impressum`
|
||||
already uses for a public page needing seller data server-side). Only
|
||||
non-sensitive fields make it into the schema (name, address, email,
|
||||
`vatID`) — `iban`/`bic` never do, even though `getCompanySettings()`
|
||||
itself returns them. Has a stable `@id`
|
||||
(`https://einfach-produktiv.mk360.de/#organization`) that `Product`/
|
||||
`BlogPosting` schemas elsewhere link back to via `{ "@id": ... }`
|
||||
instead of repeating the full object on every page (schema.org's own
|
||||
recommended pattern for a single canonical entity). Falls back to a
|
||||
minimal `{name, url}`-only Organization if `getCompanySettings()`
|
||||
can't reach the backend, rather than emitting nothing.
|
||||
- **`Product`** — only on `/todo-cards`, the one page with its own
|
||||
dedicated URL for a single, purchasable product (`getProductBySlug`).
|
||||
Deliberately not added to `/shop`'s grid — most products there have no
|
||||
individual detail page to point a `Product`'s `url` at, and Google's
|
||||
own guidance is that Product markup belongs on the page where that
|
||||
product can actually be viewed/bought, not a generic listing.
|
||||
`aggregateRating` is omitted — no reviews/ratings system exists yet
|
||||
(see the SOTA-gaps discussion this same session); add it once real
|
||||
reviews exist, don't fake it before then.
|
||||
- **`BlogPosting`** — on every `/blog/[slug]` page (`buildArticleSchema`).
|
||||
`author` is hardcoded `{ "@type": "Person", name: "Björn" }`, matching
|
||||
the page's own hardcoded author-bio block — this is a single-author
|
||||
blog with no `author` field on `Posts.ts` to read from instead.
|
||||
|
||||
Verified locally by curling each page and grepping the rendered
|
||||
`application/ld+json` script for the expected `@type` — not run through
|
||||
Google's Rich Results Test (no live Stripe-style external validation
|
||||
step for this), so worth a manual check there once deployed.
|
||||
|
||||
## Tests
|
||||
|
||||
`npm run test:unit` (Vitest, `node` environment, no jsdom/Next.js runtime
|
||||
|
||||
@@ -444,6 +444,7 @@ export async function POST(request: Request) {
|
||||
discountAmount,
|
||||
discountCode: body.discountCode || null,
|
||||
total,
|
||||
isManualPayment: true,
|
||||
},
|
||||
body.email,
|
||||
).catch((err) => {
|
||||
|
||||
@@ -26,5 +26,5 @@ export async function POST(req: Request) {
|
||||
if (!result.ok) {
|
||||
return NextResponse.json({ ok: false, reason: "Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut." }, { status: 502 });
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
return NextResponse.json({ ok: true, alreadySubscribed: result.alreadySubscribed ?? false });
|
||||
}
|
||||
|
||||
@@ -7,8 +7,9 @@ import { Reveal } from "../../components/Reveal";
|
||||
import { Footer } from "../../components/Footer";
|
||||
import { RichText } from "../../components/RichText";
|
||||
import { LivePostContent } from "./components/LivePostContent";
|
||||
import { getBlogPosts, getPostBySlug } from "../../lib/payload";
|
||||
import { getBlogPosts, getPostBySlug, getCompanySettings } from "../../lib/payload";
|
||||
import { formatDate } from "../../lib/format";
|
||||
import { buildArticleSchema } from "../../lib/structuredData";
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
@@ -61,9 +62,12 @@ export default async function BlogDetailPage({
|
||||
// showing a fake/duplicate card when this is the only post.
|
||||
const otherPosts = await getBlogPosts(4);
|
||||
const nextPost = otherPosts.find((p) => p.slug !== post.slug) ?? null;
|
||||
const seller = await getCompanySettings();
|
||||
const articleSchema = buildArticleSchema(post, seller);
|
||||
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(articleSchema) }} />
|
||||
<main className="flex flex-col flex-1 bg-bg-base">
|
||||
{isPreview ? (
|
||||
<LivePostContent initialPost={post} />
|
||||
|
||||
@@ -13,11 +13,11 @@ function LockIcon() {
|
||||
}
|
||||
|
||||
export function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabel?: string }) {
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
const { email, emailError, consent, setConsent, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("challenge");
|
||||
|
||||
if (status === "success") {
|
||||
return <p className="text-[1rem] text-[#222221] font-medium">Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.</p>;
|
||||
return <p className="text-[1rem] text-[#222221] font-medium">{successMessage}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,6 +26,7 @@ const FALLBACK: CompanySettings = {
|
||||
kleinunternehmer: false,
|
||||
iban: null,
|
||||
bic: null,
|
||||
bankName: null,
|
||||
};
|
||||
|
||||
// Entered exclusively via CompanySettings.ts's admin.livePreview.url (a
|
||||
|
||||
@@ -34,7 +34,7 @@ export function Newsletter({
|
||||
title = <>Starte mit einer Woche voller Klarheit<span className="text-brand">.</span></>,
|
||||
description = "Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.",
|
||||
}: NewsletterProps = {}) {
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
const { email, emailError, consent, setConsent, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-page");
|
||||
|
||||
return (
|
||||
@@ -92,9 +92,7 @@ export function Newsletter({
|
||||
{/* Right: form — takes remaining space, centered vertically from md+ */}
|
||||
<div className="flex w-full md:flex-1 items-center md:self-stretch min-w-0">
|
||||
{status === "success" ? (
|
||||
<p className="text-body text-text-primary font-medium">
|
||||
Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.
|
||||
</p>
|
||||
<p className="text-body text-text-primary font-medium">{successMessage}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-1 flex-col gap-4 min-w-0 w-full">
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ const features = [
|
||||
export function NewsletterModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
const { email, emailError, consent, setConsent, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-modal");
|
||||
|
||||
// Background scroll lock while open — intercepts and cancels the wheel/
|
||||
@@ -190,9 +190,7 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
|
||||
</p>
|
||||
|
||||
{status === "success" ? (
|
||||
<p className="text-body text-text-primary font-medium">
|
||||
Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.
|
||||
</p>
|
||||
<p className="text-body text-text-primary font-medium">{successMessage}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5 items-start w-full">
|
||||
<div className="flex flex-col gap-4 items-start w-full">
|
||||
|
||||
+12
-2
@@ -4,7 +4,8 @@ import "./globals.css";
|
||||
import { Navbar } from "./components/Navbar";
|
||||
import { CartFlyProvider } from "./components/CartFly";
|
||||
import { CartSync } from "./components/CartSync";
|
||||
import { getProducts, getSeoSettings } from "./lib/payload";
|
||||
import { getProducts, getSeoSettings, getCompanySettings } from "./lib/payload";
|
||||
import { buildOrganizationSchema } from "./lib/structuredData";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
@@ -66,8 +67,16 @@ export default async function RootLayout({
|
||||
// just to know whether Navbar's "Shop" link should behave as an anchor
|
||||
// to the homepage spotlight instead of a real /shop navigation (see
|
||||
// Navbar.tsx/ProductSpotlight.tsx).
|
||||
const products = await getProducts();
|
||||
const [products, seller] = await Promise.all([getProducts(), getCompanySettings()]);
|
||||
const singleActiveProduct = products.filter((p) => p.active).length === 1;
|
||||
// Organization JSON-LD on every page — one canonical node (@id) that
|
||||
// Product/Article schemas elsewhere link back to via `{ "@id": ... }`
|
||||
// instead of repeating the full seller object per page (see
|
||||
// structuredData.ts's own comment). getCompanySettings() is already the
|
||||
// established pattern for a public page needing seller data server-side
|
||||
// (see /impressum) — only non-sensitive fields (name/address/email/
|
||||
// vatID) ever make it into the rendered schema, never iban/bic.
|
||||
const organizationSchema = buildOrganizationSchema(seller);
|
||||
|
||||
return (
|
||||
<html
|
||||
@@ -75,6 +84,7 @@ export default async function RootLayout({
|
||||
className={`${inter.variable} ${playfair.variable} ${caveat.variable} ${lora.variable} h-full antialiased scroll-smooth`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }} />
|
||||
<CartFlyProvider>
|
||||
<CartSync />
|
||||
<Navbar singleActiveProduct={singleActiveProduct} />
|
||||
|
||||
+33
-7
@@ -15,11 +15,36 @@
|
||||
// Switched 2026-07-25 per explicit request once the confirmation-email
|
||||
// template existed to point templateId at.
|
||||
const BREVO_DOUBLE_OPTIN_URL = "https://api.brevo.com/v3/contacts/doubleOptinConfirmation";
|
||||
const BREVO_CONTACTS_URL = "https://api.brevo.com/v3/contacts";
|
||||
|
||||
export type BrevoSyncResult = { ok: true } | { ok: false; reason: string };
|
||||
export type BrevoSyncResult = { ok: true; alreadySubscribed?: boolean } | { ok: false; reason: string };
|
||||
|
||||
export type NewsletterOptInSource = "checkout" | "newsletter-page" | "newsletter-modal" | "newsletter-hero" | "challenge";
|
||||
|
||||
// Checked before calling doubleOptinConfirmation — that endpoint gives
|
||||
// no way to tell "brand new signup" apart from "already confirmed,
|
||||
// resending the same mail again" (verified directly: calling it a
|
||||
// second time for an already-subscribed contact still returns a plain
|
||||
// 201, same as the first time). `listIds` on a Brevo contact is only
|
||||
// populated once double opt-in actually confirms (never for a merely
|
||||
// *requested*, still-pending one), so its presence here is a reliable
|
||||
// "already subscribed to this list" signal. Fails open on any error —
|
||||
// this check is a UX nicety (skip an unnecessary resend, show a
|
||||
// friendlier message), never a reason to block a real signup attempt.
|
||||
async function isAlreadySubscribed(email: string, apiKey: string, listId: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${BREVO_CONTACTS_URL}/${encodeURIComponent(email)}`, {
|
||||
headers: { "api-key": apiKey },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!res.ok) return false; // 404 (never signed up before) or any transient error
|
||||
const contact: { listIds?: number[] } = await res.json();
|
||||
return (contact.listIds ?? []).includes(Number(listId));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// `source` becomes a Brevo contact attribute so campaigns/segments can
|
||||
// tell a checkout opt-in apart from the standalone signup forms without
|
||||
// needing separate lists.
|
||||
@@ -33,6 +58,11 @@ export async function upsertNewsletterContact(
|
||||
if (!apiKey || !listId || !templateId) {
|
||||
return { ok: false, reason: "BREVO_API_KEY/BREVO_LIST_ID/BREVO_DOUBLE_OPTIN_TEMPLATE_ID nicht konfiguriert." };
|
||||
}
|
||||
|
||||
if (await isAlreadySubscribed(email, apiKey, listId)) {
|
||||
return { ok: true, alreadySubscribed: true };
|
||||
}
|
||||
|
||||
const redirectionUrl = process.env.BREVO_DOI_REDIRECT_URL || "https://einfach-produktiv.mk360.de/newsletter-confirmed";
|
||||
|
||||
try {
|
||||
@@ -52,12 +82,8 @@ export async function upsertNewsletterContact(
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
// 201 Created is this endpoint's success status (unlike the plain
|
||||
// contacts upsert this replaced, which used 204). A contact who's
|
||||
// already confirmed-and-subscribed re-submitting the form is not
|
||||
// treated as an error either — Brevo resends the confirmation email
|
||||
// in that case rather than erroring, which is an acceptable no-op
|
||||
// resend from this app's point of view (matches the previous
|
||||
// endpoint's "always succeeds for an existing contact too" behavior).
|
||||
// contacts upsert this replaced, which used 204). The already-
|
||||
// subscribed case is handled above, before this call ever fires.
|
||||
if (res.ok || res.status === 201) return { ok: true };
|
||||
const body = await res.json().catch(() => null);
|
||||
return { ok: false, reason: body?.message ?? `Brevo antwortete mit ${res.status}` };
|
||||
|
||||
@@ -66,6 +66,29 @@ function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// Vorkasse (Überweisung/manual) instruction — the invoice PDF already
|
||||
// shows this same information (see @einfach-produktiv/invoicing's own
|
||||
// unpaidNoticeText), but a customer often only glances at the email body
|
||||
// itself, not the attached PDF, so it's repeated here in plain text too.
|
||||
// Own full-width block, margin-top matching the other section gaps in
|
||||
// this template (16px) — not squeezed into the narrow Gesamtsumme table
|
||||
// like an initial draft of the invoice version was before that got
|
||||
// widened per feedback.
|
||||
function vorkasseNotice(orderNumber: string, seller: CompanySettings | null): string {
|
||||
const bankLine = seller && (seller.iban || seller.bic)
|
||||
? [seller.bankName, seller.iban && `IBAN ${seller.iban}`, seller.bic && `BIC ${seller.bic}`].filter(Boolean).join(" · ")
|
||||
: null;
|
||||
return `
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:20px;background:${BG_MUTED};border-radius:8px;padding:16px 20px;">
|
||||
<tr>
|
||||
<td style="font-size:13px;line-height:1.6;color:${TEXT_MUTED};text-align:center;">
|
||||
Bitte überweise den Rechnungsbetrag unter Angabe der Bestellnummer ${escapeHtml(orderNumber)}${bankLine ? ` auf folgende Bankverbindung: <strong style="color:${TEXT_PRIMARY};">${escapeHtml(bankLine)}</strong>` : " auf die dir genannte Bankverbindung"}. Deine Bestellung wird nach Zahlungseingang bearbeitet (in der Regel innerhalb von 1–2 Werktagen).
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
// Live-Preview-only fallback (no real order/company-settings fetch there,
|
||||
// see /email-preview/[type]) — the actual send always passes the real
|
||||
// seller (company-settings) through buildLegalFooterLines() below. Email
|
||||
@@ -181,6 +204,14 @@ export type OrderConfirmationData = {
|
||||
discountAmount: number;
|
||||
discountCode: string | null;
|
||||
total: number;
|
||||
// Explicit boolean set by each caller (checkout route's manual branch:
|
||||
// true; the Stripe webhook path: always false, since only a *paid*
|
||||
// Stripe order ever reaches this send) — not derived from
|
||||
// paymentMethodTitle here, since that string ("Online-Zahlung",
|
||||
// "Kreditkarte", "Überweisung (Vorkasse)", ...) is exactly the kind of
|
||||
// fragile thing a payment-methods rename already broke once this
|
||||
// session (see @einfach-produktiv/invoicing's isPaidImmediately()).
|
||||
isManualPayment: boolean;
|
||||
};
|
||||
|
||||
export const SAMPLE_ORDER: OrderConfirmationData = {
|
||||
@@ -202,6 +233,7 @@ export const SAMPLE_ORDER: OrderConfirmationData = {
|
||||
discountAmount: 5,
|
||||
discountCode: "WILLKOMMEN10",
|
||||
total: 37.7,
|
||||
isManualPayment: false,
|
||||
};
|
||||
|
||||
export function renderOrderConfirmationHtml(template: EmailTemplateContent, order: OrderConfirmationData, seller: CompanySettings | null): string {
|
||||
@@ -263,6 +295,7 @@ export function renderOrderConfirmationHtml(template: EmailTemplateContent, orde
|
||||
</tr>
|
||||
${taxRows}
|
||||
</table>
|
||||
${order.isManualPayment ? vorkasseNotice(order.orderNumber, seller) : ""}
|
||||
`;
|
||||
|
||||
return emailShell("✓", escapeHtml(template.heading), body, template.footerText, buildLegalFooterLines(seller));
|
||||
|
||||
@@ -882,6 +882,7 @@ export type CompanySettings = {
|
||||
kleinunternehmer: boolean;
|
||||
iban: string | null;
|
||||
bic: string | null;
|
||||
bankName: string | null;
|
||||
};
|
||||
|
||||
// Server-only in practice (only ever called from app/lib/invoiceData.ts),
|
||||
|
||||
@@ -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).
|
||||
@@ -9,12 +9,20 @@ import type { NewsletterOptInSource } from "./brevo";
|
||||
// hero form, /challenge's EmailCapture) — four places with the same
|
||||
// email+consent+submit shape but different markup/visual style, so only
|
||||
// the logic is shared here rather than a one-size-fits-all component.
|
||||
// Same two sentences every form already showed hardcoded (per the user's
|
||||
// own explicit wording request, see the newsletter-DOI memory) — kept
|
||||
// here once instead of duplicated across all 4 forms now that a second
|
||||
// variant (already subscribed) needs the same treatment.
|
||||
const SUCCESS_MESSAGE = "Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.";
|
||||
const ALREADY_SUBSCRIBED_MESSAGE = "Diese E-Mail-Adresse ist schon für unseren Newsletter angemeldet.";
|
||||
|
||||
export function useNewsletterSignup(source: NewsletterOptInSource) {
|
||||
const [email, setEmail] = useState("");
|
||||
const [emailError, setEmailError] = useState("");
|
||||
const [consent, setConsent] = useState(false);
|
||||
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
|
||||
const [error, setError] = useState("");
|
||||
const [successMessage, setSuccessMessage] = useState(SUCCESS_MESSAGE);
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function handleEmailChange(value: string) {
|
||||
@@ -48,6 +56,7 @@ export function useNewsletterSignup(source: NewsletterOptInSource) {
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
setSuccessMessage(data.alreadySubscribed ? ALREADY_SUBSCRIBED_MESSAGE : SUCCESS_MESSAGE);
|
||||
setStatus("success");
|
||||
} catch {
|
||||
setError("Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut.");
|
||||
@@ -55,5 +64,5 @@ export function useNewsletterSignup(source: NewsletterOptInSource) {
|
||||
}
|
||||
}
|
||||
|
||||
return { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit };
|
||||
return { email, emailError, consent, setConsent, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit };
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ const checklist = [
|
||||
];
|
||||
|
||||
export function WeeklyImpulsesHero() {
|
||||
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
const { email, emailError, consent, setConsent, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
|
||||
useNewsletterSignup("newsletter-hero");
|
||||
|
||||
return (
|
||||
@@ -111,9 +111,7 @@ export function WeeklyImpulsesHero() {
|
||||
Newsletter component's panel form (no button-adjacent styling
|
||||
needed here, just input + submit inline). */}
|
||||
{status === "success" ? (
|
||||
<p className="text-body text-text-primary font-medium">
|
||||
Fast geschafft! Schau kurz in dein Postfach – da wartet schon eine Mail von uns.
|
||||
</p>
|
||||
<p className="text-body text-text-primary font-medium">{successMessage}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-3 items-start w-full">
|
||||
{/* flex-col sm:flex-row, no items-start at the base tier —
|
||||
|
||||
+11
-2
@@ -7,7 +7,8 @@ import { Pricing } from "./components/Pricing";
|
||||
import { Footer } from "../components/Footer";
|
||||
import { TestimonialsGrid } from "../components/TestimonialsGrid";
|
||||
import { LiveTestimonialsGrid } from "../components/LiveTestimonialsGrid";
|
||||
import { getTestimonials } from "../lib/payload";
|
||||
import { getTestimonials, getProductBySlug, getCompanySettings } from "../lib/payload";
|
||||
import { buildProductSchema } from "../lib/structuredData";
|
||||
|
||||
const title = "ToDo-Karten – Kleine Karten. Große Wirkung.";
|
||||
const description =
|
||||
@@ -34,10 +35,18 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function TodoCardsPage() {
|
||||
const { isEnabled: isPreview } = await draftMode();
|
||||
const testimonials = await getTestimonials("todo-cards", { draft: isPreview });
|
||||
const [testimonials, product, seller] = await Promise.all([
|
||||
getTestimonials("todo-cards", { draft: isPreview }),
|
||||
getProductBySlug("todo-karten"),
|
||||
getCompanySettings(),
|
||||
]);
|
||||
const productSchema = product ? buildProductSchema(product, "https://einfach-produktiv.mk360.de/todo-cards", seller) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{productSchema && (
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(productSchema) }} />
|
||||
)}
|
||||
<main className="flex flex-col flex-1">
|
||||
<TodoKartenHero />
|
||||
<HowItWorks />
|
||||
|
||||
Generated
+2
-2
@@ -455,8 +455,8 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@einfach-produktiv/invoicing": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#8dc56c9783186d91cce5c659dc8fa77e673e9324",
|
||||
"version": "0.2.4",
|
||||
"resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#b3bd5c3f075b0550ce1886129cc729829875e570",
|
||||
"dependencies": {
|
||||
"@e-invoice-eu/core": "^3.1.1"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user