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
+75 -1
View File
@@ -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 12 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
+1
View File
@@ -444,6 +444,7 @@ export async function POST(request: Request) {
discountAmount,
discountCode: body.discountCode || null,
total,
isManualPayment: true,
},
body.email,
).catch((err) => {
+1 -1
View File
@@ -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 });
}
+5 -1
View File
@@ -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} />
+2 -2
View File
@@ -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 (
+1
View File
@@ -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
+2 -4
View File
@@ -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">
+2 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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}` };
+33
View File
@@ -66,6 +66,29 @@ function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
// 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 12 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));
+1
View File
@@ -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),
+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).
+10 -1
View File
@@ -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
View File
@@ -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 />
+2 -2
View File
@@ -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"
},