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
|
||||
|
||||
Reference in New Issue
Block a user