1706da8598
- 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>
94 lines
4.4 KiB
TypeScript
94 lines
4.4 KiB
TypeScript
// Server-only — syncs newsletter opt-ins to Brevo's Contacts API via the
|
|
// double-opt-in endpoint: this only ever *requests* a subscription, it
|
|
// does not add the contact to the real list itself — Brevo sends the
|
|
// confirmation email (the template at BREVO_DOUBLE_OPTIN_TEMPLATE_ID,
|
|
// configured as this list's Double Opt-in template in Brevo's own UI) and
|
|
// only adds the contact to BREVO_LIST_ID once they click through. This
|
|
// app never sends marketing mail itself, and — as of this switch — never
|
|
// even directly grants list membership; it only ever hands Brevo the
|
|
// contact + consent-to-be-asked. Everything after that (the confirmation
|
|
// email itself, the post-confirmation Welcome Flow automation) is
|
|
// configured in Brevo's own UI, not manageable via their public API.
|
|
//
|
|
// Previously called the plain `POST /v3/contacts` upsert (single
|
|
// opt-in — added straight to the list, no confirmation click required).
|
|
// 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; 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.
|
|
export async function upsertNewsletterContact(
|
|
email: string,
|
|
source: NewsletterOptInSource,
|
|
): Promise<BrevoSyncResult> {
|
|
const apiKey = process.env.BREVO_API_KEY;
|
|
const listId = process.env.BREVO_LIST_ID;
|
|
const templateId = process.env.BREVO_DOUBLE_OPTIN_TEMPLATE_ID;
|
|
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 {
|
|
const res = await fetch(BREVO_DOUBLE_OPTIN_URL, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"api-key": apiKey,
|
|
},
|
|
body: JSON.stringify({
|
|
email,
|
|
includeListIds: [Number(listId)],
|
|
templateId: Number(templateId),
|
|
redirectionUrl,
|
|
attributes: { OPT_IN_SOURCE: source },
|
|
}),
|
|
signal: AbortSignal.timeout(8000),
|
|
});
|
|
// 201 Created is this endpoint's success status (unlike the plain
|
|
// 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}` };
|
|
} catch (err) {
|
|
return { ok: false, reason: err instanceof Error ? err.message : "Brevo ist gerade nicht erreichbar." };
|
|
}
|
|
}
|