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
+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}` };