Compare commits

...

27 Commits

Author SHA1 Message Date
Marco 94cba756c8 Remove trailing period from Werkzeuge section heading
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 09:58:32 +00:00
Marco b835e8622f Align newsletter modal copy with the homepage newsletter block
NewsletterModal.tsx advertised a different offer (the 7-Tage-Challenge)
than Newsletter.tsx's own description (7 Impulse) for the same signup
flow. Also rewrote both descriptions to drop the vague "und gewinne
Klarheit" buzzword formula and the word "Klarheit" repeated from the
heading directly above it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 09:58:27 +00:00
Marco 7286c76787 fixed hero title 2026-07-29 08:54:35 +00:00
Marco e175d2b4c3 Bump @einfach-produktiv/invoicing to 0.2.6 (embedded-font fix)
Picks up the Liberation Sans font-embedding fix for PDF/A-3 compliance
— fixes invoice PDF downloads/emails generated by this app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-28 23:53:51 +00:00
Marco 1306dce6e4 Text updates for customer 2026-07-28 21:39:30 +00:00
Marco 8fa4a20e63 Document the active/inactive toggle and fix stale "6 rows" references
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 23:04:44 +00:00
Marco 1d875a65c0 Honor email-templates.active for order-confirmation
Skips the send entirely when the admin has deliberately deactivated
the order-confirmation template, matching the backend's new toggle for
the 7 order-status emails. Distinct from a missing row, which still
sends with the hardcoded default wording.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:58:41 +00:00
Marco 0436e87147 Document the 3 new email types and the cross-origin Live Preview limitation
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:43:51 +00:00
Marco 7feda76b3c Use 🎉 for order-delivered instead of 🥳
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:14:22 +00:00
Marco c4fb6586e7 Match order-delivered's Live Preview icon to the backend's 🥳
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 22:13:36 +00:00
Marco 3dccc229af Add order-delivered to Live Preview support
Matches the backend's new opt-in review/follow-up email type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 21:58:09 +00:00
Marco 6c43171b11 Fix Live Preview 404 for the two new tracking email templates
order-tracking-added/order-tracking-corrected were missing from
VALID_TYPES (app/email-preview/[type]/page.tsx), EmailTemplateType
(app/lib/payload.ts), and ORDER_STATUS_EMAIL_ICON (emailTemplates.ts)
— Live Preview 404'd for both since they were added to the Payload
collection without a matching frontend update.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:35:07 +00:00
Marco 8d4ee75cfb Document newsletter-modal fixes in the README
Error-clears-on-interaction behavior and the modal photo no longer
resizing when the already-subscribed/format-error message appears.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 20:19:44 +00:00
Marco 8a910f7ff8 Bump @einfach-produktiv/invoicing to 0.2.5
Moves the §19/§4-Nr.-1b-UStG notice out of the invoice summary card
into its own full-width row, matching the Vorkasse-notice fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 19:49:02 +00:00
Marco a7fa0ec027 Reserve space for newsletter-modal error text so the photo doesn't resize
The modal's left photo stretches (items-stretch, md:aspect-auto) to
match the right column's height. The "already subscribed"/invalid-email
messages were conditionally mounted, so their appearance grew the right
column and dragged the photo's height along with it. Both are now
always rendered with a reserved min-height instead, so toggling them no
longer changes the modal's size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:52:13 +00:00
Marco 0ac2e45077 Clear "already subscribed" newsletter error on next interaction
Matches standard form-validation behavior: any further edit to the
email or consent checkbox after the already-subscribed message
appears now dismisses it, instead of leaving it stuck until submit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:34:20 +00:00
Marco fd98f68ba6 Route already-subscribed through the error state, not success
Per explicit feedback: swapping the whole form out for a bare success
message felt wrong for "you're already signed up, nothing to do" — the
form stays visible with a small red note below it instead, same as
every other inline validation error. No per-form UI changes needed,
they already render the error state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:27:50 +00:00
Marco bccfc28012 Log the real newsletter-subscribe failure reason server-side
The customer-facing message stays generic on purpose (never leak
Brevo's internal error text), but the real reason was discarded
entirely before this — every failure looked identical from the
outside. Cost real debugging time today tracking down a misconfigured
BREVO_LIST_ID in Coolify (was "2", should have been "5") that made
every single signup attempt fail with the same unhelpful message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:25:01 +00:00
Marco 55df2e104c Give the Vorkasse bank details their own line in the confirmation email
Was one run-on sentence; split into three paragraphs: the instruction,
the bank details on their own bold line, then the processing-time note
with a line break after the bank line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 17:20:15 +00:00
Marco 1706da8598 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>
2026-07-25 17:11:48 +00:00
Marco 1dbc0c31ff Drop "neu" from the checkout account-password copy
"Bitte ein Passwort für dein neues Konto vergeben" / "Passwort (für
dein neues Konto)" read oddly — just "dein Konto".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 16:41:02 +00:00
Marco 06a00d67e4 Fix groupPaymentMethodsForCheckout ignoring sortOrder
Hardcoded manual rows first, then the combined stripe option — so
"Online-Zahlung" (sortOrder 0) showed after "Überweisung (Vorkasse)"
(sortOrder 1) in checkout, contradicting the admin's own ordering.
Now preserves the fetch's sortOrder-sorted order, splicing the combined
entry in at the position of the first stripe row encountered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 16:39:56 +00:00
Marco 952b902702 Document a concrete Stripe activation checklist
Everything code-side is already live — this is purely the remaining
provisioning steps (test keys -> Coolify -> backend .env -> redeploy ->
end-to-end test -> live keys), so the next person doesn't have to
reconstruct the sequence from scattered comments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 16:17:32 +00:00
Marco f959ebb998 Bump @einfach-produktiv/invoicing for the Vorkasse invoice fix
Fixes isPaidImmediately() incorrectly showing "Bereits beglichen" on
unpaid Überweisung (Vorkasse) invoices after the payment-methods rename
earlier today, plus adds the missing "please transfer to this account,
processed after payment received" instruction text.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 16:12:27 +00:00
Marco bae23775f2 Hide failed-payment order attempts from the customer's own order history
A cancelled order with no invoiceNumber is a Stripe payment that never
succeeded (failed or timed out before ever reaching received/invoiced),
not a real cancellation of something that actually happened — from the
customer's point of view it was never really an order. Filtered out of
getCustomerOrders/getCustomerOrderDetail by default; the row stays in
Payload for admin/audit purposes (shown there as "Zahlung
fehlgeschlagen", see backend).

getCustomerOrderDetail's filter is opt-in via a new optional parameter,
not the default — /api/checkout/status/route.ts's post-payment polling
needs to keep seeing exactly this order to show the "Zahlung
fehlgeschlagen, bitte erneut versuchen" retry state. The GDPR export
route also opts out for the same reason a legal completeness export
can't silently drop rows.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 16:05:26 +00:00
Marco 0e12ab1f1e Merge Stripe payment integration + Brevo double opt-in newsletter
- Real payment capture for Kreditkarte/PayPal via Stripe's Payment
  Element, webhook-gated order confirmation, PAYMENT_TEST_MODE for
  local testing without a real Stripe account.
- Newsletter signup switched from single to double opt-in
  (POST /contacts/doubleOptinConfirmation), plus /newsletter-confirmed
  as the post-confirmation landing page.

Backend counterpart already deployed and verified (confirm-payment
endpoint live, payment-methods provider field set on Kreditkarte/PayPal).
STRIPE_SECRET_KEY/STRIPE_WEBHOOK_SECRET/NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
still unset in Coolify — PAYMENT_TEST_MODE auto-engages until then, so
checkout is safe to test without a real Stripe account.
BREVO_DOUBLE_OPTIN_TEMPLATE_ID is set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 15:25:52 +00:00
Marco cb4c9d77f2 Fix icon circle collapsing narrow in strict HTML-attribute-stripping renderers
The width="56" height="56" HTML attributes on the icon badge <td> weren't
mirrored in its inline style — renderers that ignore/strip HTML
width/height (Brevo's own editor among them, per a real report) shrink
the cell to fit the glyph instead of staying a 56px circle. Add explicit
width:56px;height:56px to the style too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 14:48:45 +00:00
26 changed files with 601 additions and 73 deletions
+227 -11
View File
@@ -120,7 +120,7 @@ explains what does have access instead.
| `orders` | Persisted checkout orders, `/konto/bestellungen*` | `orderNumber`, `invoiceNumber`/`invoiceIssuedAt`, `correctionInvoiceNumber`/`correctionInvoiceIssuedAt` (see "Invoice PDFs" below), `status` (`received`/`processing`/`shipped`/`delivered`/`cancelled`/`return_requested`/`returned` — the first 4 maintained by hand in the admin, no carrier API; the rest see "Order cancellation & returns"), `returnReason` (captured from the customer on a return request), full address/items (each with a snapshotted `taxRatePercent`/`bundleContents`)/totals at order time. **Not public-read** — created only via `ORDER_SERVICE_SECRET`, read/updated by admin or the order's own customer |
| `customers` | Storefront accounts — register/login/order-history, a second `auth: true` collection separate from the Payload admin's own `users` login | `customerNumber`, `firstName`/`lastName`/`email`, one default address, `cart` (server-side mirror), `emailVerified` (non-blocking). **Not public-read** — see "Orders & customer accounts" below |
| `number-ranges` | Admin-configurable prefix + running counter for customer/order/invoice/correction-invoice numbers — one row per tenant | `customerPrefix`/`customerNext`/`customerPadding`, `orderPrefix`/`orderNext`/`orderPadding`, `invoicePrefix`/`invoiceNext`/`invoicePadding`, `correctionInvoicePrefix`/`correctionInvoiceNext`/`correctionInvoicePadding` (Stornorechnung/Gutschrift — its own gapless sequence, not the same counter as `invoice*`, see "Invoice PDFs" below). **Admin-only**, no frontend read at all — internal to the two `beforeChange` hooks that assign these numbers |
| `email-templates` | Editable subject/heading/body/footer for all 6 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | `type` (`order-confirmation`/`password-reset`/`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`), `subject`, `heading`, `bodyText`, `footerText`. Public-read, has a Live Preview button |
| `email-templates` | Editable subject/heading/body/footer for all 9 transactional emails this shop sends (see "Email templates & Live Preview" and "Status-change emails" below) | `type` (`order-confirmation`/`password-reset`/`order-shipped`/`order-cancelled`/`order-return-requested`/`order-returned`/`order-tracking-added`/`order-tracking-corrected`/`order-delivered`), `subject`, `heading`, `bodyText`, `footerText`. Public-read, has a Live Preview button |
| `company-settings` | Structured business data for invoice PDFs *and* every email's legal footer (Anbieterkennzeichnung, see "Invoice PDFs" below) — one row per tenant, own **Company** admin group (not Commerce — this is business identity, not a storefront concern) | `sellerName`/`sellerStreet`/`sellerZip`/`sellerCity`/`sellerCountry`/`sellerEmail`, `vatId`, `taxRatePercent` (admin-editable, not hardcoded), `bankName`/`iban`/`bic` (`iban`/`bic` format-validated + uppercase-normalized; `bankName` stays free text — replaced a single free-text `bankDetails` field). **Not public-read** — admin or `ORDER_SERVICE_SECRET`. Has a Live Preview button — see "Company Settings & Live Preview" below |
All of the above except `company-settings`, `media`, `users`, `tenants`
@@ -374,7 +374,12 @@ exactly as before: no gateway involved, order goes straight to `received`.
(`app/lib/payments/confirmPaymentEmail.ts`, only when the response isn't
`alreadyProcessed: true` — a repeat webhook delivery must never resend
it), mirroring exactly what the checkout route already does inline for
a manual/Überweisung order.
a manual/Überweisung order. Product photos in that snapshot's
`items[].imageUrl` need no frontend change to work —
`ConfirmPaymentOrderSnapshot`/`OrderConfirmationItem` already typed the
field; the backend just wasn't populating it (fixed there, see its own
README — needed `depth: 2` so `item.product.image` resolves to a real
`Media` doc).
- **`/checkout/verarbeitung`** (`VerarbeitungContent.tsx`) is the
`return_url` target. Neither a client-side `confirmPayment()` success nor
landing back from a PayPal redirect is trusted as proof of payment on its
@@ -407,6 +412,68 @@ Stripe API call itself that's faked. That test-confirm route hard-404s
whenever `PAYMENT_TEST_MODE` isn't explicitly true, so it can never become
a reachable "mark any order paid" endpoint in production.
**Activating real Stripe payments — checklist.** Everything code-side is
already live (both `main` branches deployed); this is purely
provisioning. Nothing here is required to *test* the flow today —
`PAYMENT_TEST_MODE` already works end to end with zero Stripe account.
Already done, as of 2026-07-25:
- [x] `PAYMENT_WEBHOOK_SECRET` set in Coolify, matches the backend's copy
in `docker/.env` — needed even in test mode (the test-confirm route
sends it as a header the backend checks).
- [x] `payment-methods` rows configured: Kreditkarte/PayPal →
`provider: 'stripe'`; "Überweisung (Vorkasse)" → `provider: 'manual'`
(untouched by anything below); "Sofortüberweisung" prepared as
`provider: 'stripe'` but `active: false` (needs a logo before switching
on — see the backend's own README).
- [x] Database migration applied to production, confirmed live.
Still needed, in order:
1. **Create a Stripe account** (free). Stay in **test mode** first (toggle
top of the Stripe dashboard) — nothing below moves real money until
step 5.
2. **Copy the test API keys***Entwicklerbereich → API-Schlüssel*:
`sk_test_...` and `pk_test_...`.
3. **Create a webhook endpoint** — *Entwicklerbereich → Webhooks → Endpoint
hinzufügen*, URL `https://einfach-produktiv.mk360.de/api/webhooks/stripe`,
events: at minimum `payment_intent.succeeded` and
`payment_intent.payment_failed`. Copy the signing secret, `whsec_...`.
4. **Set these three in Coolify** (`einfach-produktiv` app → Environment
Variables):
| Variable | Value |
|---|---|
| `STRIPE_SECRET_KEY` | `sk_test_...` |
| `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY` | `pk_test_...` |
| `STRIPE_WEBHOOK_SECRET` | `whsec_...` |
The moment `STRIPE_SECRET_KEY` is set, `PAYMENT_TEST_MODE` switches off
automatically (see above) — the real Payment Element replaces the mock
buttons. Still 100% safe: Stripe's own test mode only accepts test card
numbers (e.g. `4242 4242 4242 4242`), no real charge is possible.
5. **Set the same `sk_test_...` in the backend** too —
`/home/marco/dev/docker/.env`'s `STRIPE_SECRET_KEY` (used only for
`stripeRefund.ts`, Storno/Gutschrift refunds) — then
`cd ~/dev/docker && docker compose build payload && docker compose up -d payload`
to pick it up.
6. **Redeploy the frontend** so it picks up the new Coolify env vars —
either wait for the next git push (Coolify redeploys on push) or
trigger one directly: `curl -X POST https://coolify.mk360.de/deploy/einfach-produktiv`.
7. **Test end to end**: place a real order with a Stripe test card,
confirm the order flips to `received`, invoice/confirmation email
arrive, product images show, `Orders.paymentStatus` reads `paid`. Try a
declined test card too (e.g. `4000 0000 0000 0002`) and confirm the
order shows "Zahlung fehlgeschlagen" in the admin, not in the
customer's own order history.
8. **Trigger a Storno on a paid test order** in the admin, confirm the
refund job actually calls Stripe (check the PaymentIntent in the Stripe
dashboard) and `Orders.refundStatus` updates.
9. **Go live**: only once ready for real charges — verify the Stripe
account for live payments (business details), switch the dashboard to
**live mode**, repeat steps 26 with the live-mode keys (`sk_live_...`/
`pk_live_...`, a *new* webhook endpoint registered in live mode → new
`whsec_...`) — these replace the test values in both Coolify and
`docker/.env`, not additional variables.
### VAT display
Every price shown storefront-wide says "inkl. X% MwSt." with the *actual*
@@ -444,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`,
@@ -1081,12 +1166,44 @@ 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. 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. **Routed through the
existing error state, not a success variant** — per explicit feedback,
swapping the whole form out for a bare message (the real-success
treatment) felt wrong for "you're already signed up, nothing to do"; the
form stays visible with a small red note below it instead, exactly like
every other inline validation error (`useNewsletterSignup.ts` sets
`status: "error"`, `error: "Diese E-Mail-Adresse ist schon für unseren
Newsletter angemeldet."` — no new UI needed in any of the 4 forms, they
already render `{status === "error" && <p className="text-red-600
...">{error}</p>}`).
- **`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 real-success
message text also now lives here (`successMessage`) instead of
hardcoded 4 times per form.
- **A misconfigured `BREVO_LIST_ID` in Coolify (`2` instead of `5`) broke
every newsletter signup in production for a stretch of time** —
discovered and fixed 2026-07-25 while testing the already-subscribed
feature above. The generic customer-facing error message ("Anmeldung ist
fehlgeschlagen...") gave no hint why; `upsertNewsletterContact()`'s real
`reason` was silently discarded by `/api/newsletter/subscribe/route.ts`
before this, now `console.error`'d server-side (message still stays
generic to the customer — never leak Brevo's internal error text, just
no longer *undiagnosable*).
- **`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
@@ -1110,6 +1227,21 @@ unsynced.
also needs `BREVO_DOUBLE_OPTIN_TEMPLATE_ID` (no safe default — every
signup silently no-ops without it) and optionally
`BREVO_DOI_REDIRECT_URL`.
- **The already-subscribed error now clears on the next interaction**
(2026-07-25), matching standard form-validation behavior instead of
sitting there until the next submit — `useNewsletterSignup.ts`'s
`handleEmailChange`/new `handleConsentChange` both call a shared
`clearSubmitError()` that resets `status`/`error` back to idle. This
replaced the hook's previously-exposed raw `setConsent` with
`handleConsentChange` in its return value — all 4 consuming forms
updated to match.
- **`NewsletterModal.tsx`'s photo no longer resizes when the error
appears** (2026-07-25) — the modal's left photo stretches
(`items-stretch`, `md:aspect-auto`) to match the right column's height,
so the emailError/already-subscribed messages growing that column used
to visibly grow the photo along with it. Both messages are now always
rendered with a reserved `min-h-[1.05rem]` instead of conditionally
mounted, so toggling them no longer changes the column's height at all.
## Orders & customer accounts
@@ -1172,6 +1304,24 @@ ref isn't attached to anything yet at that exact synchronous point.
`cancelled`/`return_requested`/`returned`) is maintained by hand in the
Payload admin for the shipping states — no shipping-carrier API
integration.
- **A `cancelled` Stripe order with no `invoiceNumber` never shows up
here** — that's a `pending_payment` order whose payment failed or timed
out (see the backend's `expirePendingPayments`/`confirmPayment.ts`), not
a real Storno (which is always of an already-`received`, already-
invoiced order, so it always has an `invoiceNumber`). From the
customer's point of view a payment that never went through was never
really an order, so `getCustomerOrders`/`getCustomerOrderDetail`
(`app/lib/customerAuth.ts`) filter these out by default — the row still
exists in Payload for admin/audit purposes (shown there as "Zahlung
fehlgeschlagen", see the backend's own README), just not surfaced to
the customer. `getCustomerOrderDetail` takes this as an **optional**
4th param, defaulting `false``/api/checkout/status/route.ts`'s
post-payment polling deliberately calls it unfiltered, since that flow
needs to keep seeing exactly this order (to show "Zahlung
fehlgeschlagen, bitte erneut versuchen") for the one case this filter
would otherwise hide. `/api/account/export/route.ts`'s GDPR export also
opts out (`false`) — a legal completeness export can't silently drop
rows.
- **`Navbar.tsx`'s `AccountLink`** (account icon, always visible in the
header itself — not duplicated inside the mobile fullscreen menu, see
"Mobile navigation" below) is the only *always*-reachable way into
@@ -1315,13 +1465,33 @@ links to `/konto/passwort-vergessen`.
### Email templates & Live Preview
All 6 transactional emails (order confirmation, password reset, and the 4
status-change types below) read their subject/heading/body/footer wording
from Payload's `email-templates` collection — editable in the admin
without a deploy, with a Live Preview button using the exact same
mechanism as Posts/LegalPages/Testimonials (`useLivePreview()` from
`@payloadcms/live-preview-react`, already a dependency here for
`LivePostContent.tsx`).
All 9 transactional emails (order confirmation, password reset, and the 7
status-change types below — the original 4 plus `order-tracking-added`/
`order-tracking-corrected`/`order-delivered`, added 2026-07-25) read their
subject/heading/body/footer wording from Payload's `email-templates`
collection — editable in the admin without a deploy, with a Live Preview
button using the exact same mechanism as Posts/LegalPages/Testimonials
(`useLivePreview()` from `@payloadcms/live-preview-react`, already a
dependency here for `LivePostContent.tsx`).
**Live Preview only reliably updates when popped out into its own
browser tab/window, not in the embedded admin panel.** Root cause: the
preview goes through `buildPreviewUrl()` on the Payload side, which hits
`/api/preview` on this frontend's own origin to enable Next.js Draft
Mode via a cookie — but admin (`payload.mk360.de`) and frontend
(`einfach-produktiv.mk360.de`) are different origins, so from inside the
admin's embedded `<iframe>` that's a cross-site/third-party context.
Modern browsers (Safari by default, Chrome/Firefox increasingly)
block or partition cookies set inside a cross-site iframe regardless of
which domain actually issued them, so the Draft Mode cookie doesn't
reliably persist there. Once popped into its own window it's a top-level
navigation, not a third-party context, so the cookie sets normally and
everything works. Affects every Live-Preview-enabled collection in this
system (Posts/LegalPages/Testimonials too), not just email templates —
a structural consequence of running admin and frontend on separate
domains, not something fixable at the collection-config level. A real
fix would mean serving both under one domain (reverse proxy path) rather
than two subdomains.
- **`app/lib/emailTemplates.ts`** — pure string-building functions
(`renderOrderConfirmationHtml()`, `renderPasswordResetHtml()`,
@@ -1346,8 +1516,16 @@ mechanism as Posts/LegalPages/Testimonials (`useLivePreview()` from
- The *real* send always reads the **published** template
(`getEmailTemplate()` in `app/lib/payload.ts`, `draft` unset) — a Live
Preview edit never affects a live customer email until actually saved.
- **`active` toggle (2026-07-25)** — each row now has an `active`
checkbox (backend `EmailTemplates.ts`); off suppresses the send
entirely, checked here for `order-confirmation`
(`orderEmail.ts`'s `sendOrderConfirmationEmail()`) before the hardcoded
default-wording fallback ever applies. See the backend repo's own
README ("Email templates: active/inactive toggle") for the full
picture, including the `password-reset` exception (Payload's core
`forgotPassword` operation has no hook to actually cancel that send).
- `npx payload run src/seed-email-templates.ts` (Payload repo) seeds
defaults for all 6 rows — deliberately on-brand and a little playful
defaults for all 9 rows — deliberately on-brand and a little playful
("Bestellt!" / "Kein Drama." / "Unterwegs!" / "Storniert." / "Alles
klar." / "Alles erledigt.", not generic transactional-email
boilerplate), matching this site's voice elsewhere (see e.g. the
@@ -1639,6 +1817,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
+2 -2
View File
@@ -10,9 +10,9 @@ export async function GET() {
if (!session) return NextResponse.json({ ok: false, reason: "Bitte zuerst einloggen." }, { status: 401 });
const profile = await getCustomerProfile(session.token);
const orderSummaries = await getCustomerOrders(session.token, session.customer.id);
const orderSummaries = await getCustomerOrders(session.token, session.customer.id, false);
const orders = await Promise.all(
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber)),
orderSummaries.map((o) => getCustomerOrderDetail(session.token, session.customer.id, o.orderNumber, false)),
);
const payload = {
+2 -1
View File
@@ -126,7 +126,7 @@ export async function POST(request: Request) {
customer = session.customer;
} else {
if (!body.password) {
return NextResponse.json({ ok: false, reason: "Bitte ein Passwort für dein neues Konto vergeben." }, { status: 400 });
return NextResponse.json({ ok: false, reason: "Bitte ein Passwort für dein Konto vergeben." }, { status: 400 });
}
const result = await registerCustomer({
firstName: body.firstName,
@@ -444,6 +444,7 @@ export async function POST(request: Request) {
discountAmount,
discountCode: body.discountCode || null,
total,
isManualPayment: true,
},
body.email,
).catch((err) => {
+9 -1
View File
@@ -24,7 +24,15 @@ export async function POST(req: Request) {
const source = body.source && VALID_SOURCES.includes(body.source) ? body.source : "newsletter-page";
const result = await upsertNewsletterContact(email, source);
if (!result.ok) {
// The customer-facing message stays generic on purpose (never leak
// Brevo's internal error text to a customer) — but the real reason
// was previously discarded entirely, which cost real debugging time
// tracking down a misconfigured BREVO_LIST_ID in Coolify (2026-07-25):
// every failure looked identical from the outside, whether it was a
// bad env var, a Brevo outage, or something else. Logged here so it's
// at least diagnosable from the container's own logs going forward.
console.error(`newsletter subscribe failed for source=${source}: ${result.reason}`);
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} />
+3 -3
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, handleConsentChange, 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 (
@@ -60,7 +60,7 @@ export function EmailCapture({ buttonLabel = "Challenge starten" }: { buttonLabe
type="checkbox"
required
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
onChange={(e) => handleConsentChange(e.target.checked)}
className="size-4 shrink-0 mt-0.5 rounded-xs border border-[#d9d9d9] accent-[#f6a701]"
/>
<span className="text-[0.8rem] text-[#444] leading-normal">
+1 -1
View File
@@ -911,7 +911,7 @@ export function CheckoutContent({
) : (
<div className="flex flex-col gap-2 w-full sm:w-[calc(50%-0.5rem)] sm:flex-none min-w-0">
<FormField
label="Passwort (für dein neues Konto)"
label="Passwort (für dein Konto)"
name="password"
type="password"
placeholder="Mind. 8 Zeichen"
+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
+1 -1
View File
@@ -52,7 +52,7 @@ export function Divider() {
</div>
<div className="flex items-center gap-3 sm:gap-8 shrink-0">
<Word>Entlastung</Word>
<Word>Leichtigkeit</Word>
{/* Sparkle icon — sizes now fluid (--divider-sparkle-*) to match
the text-h2 words next to it, previously hard rem values that
+4 -4
View File
@@ -65,7 +65,7 @@ export function Hero() {
style={{ fontFamily: "var(--font-playfair)" }}
>
<span className="text-h1 lg:text-display">
Produktivität darf<br className="hidden sm:inline md:hidden" /> sich leicht anfühlen
Verliere dich nicht<br className="hidden sm:inline md:hidden" /> im mehr. Finde heraus, was wichtig ist.
</span>
{/* Brand's signature orange dot (also in the logo/footer) —
bouncy pop-in once the heading scrolls into view, timed to
@@ -84,7 +84,7 @@ export function Hero() {
own 20px floor read too large next to the now-smaller CTA
text. leading shrinks to match, not just font-size. */}
<p className="font-semibold leading-[1.75rem] lg:leading-[2.375rem] min-w-full shrink-0 text-text-primary text-[1rem] lg:text-h-emphasis w-[min-content] [word-break:break-word] not-italic">
Für Menschen mit Familie, Verantwortung und zu wenig Zeit
Ich helfe dir, zwischen Job, Familie und eigenen Projekten nicht unterzugehen
</p>
{/* CTA */}
@@ -100,7 +100,7 @@ export function Hero() {
too wide for that, both on a 375px phone AND in the
~283-320px Tablet grid column. */}
<span className="font-semibold leading-[2.375rem] text-text-primary text-[0.8125rem] lg:text-h3 whitespace-nowrap not-italic">
Starte mit der 7-Tage-Challenge
Jetzt starten
</span>
{/* Scaled down to match the smaller CTA text (same ~0.76
aspect ratio as the lg: size), full size again from lg: up
@@ -127,7 +127,7 @@ export function Hero() {
))}
</div>
<p className="flex-[1_0_0] font-normal leading-[1.5rem] text-text-primary text-body text-center [word-break:break-word]">
10.000+ Menschen vertrauen <span className="whitespace-nowrap">einfach-produktiv</span>
1.120+ Menschen vertrauen <span className="whitespace-nowrap">einfach-produktiv</span>
</p>
</div>
</Reveal>
+4 -6
View File
@@ -32,9 +32,9 @@ type NewsletterProps = {
*/
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.",
description = "Melde dich an und bekommst 7 kurze Impulse direkt ins Postfach in ein paar Minuten gelesen, sofort im Alltag umsetzbar.",
}: NewsletterProps = {}) {
const { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
const { email, emailError, consent, handleConsentChange, 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">
@@ -140,7 +138,7 @@ export function Newsletter({
type="checkbox"
required
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
onChange={(e) => handleConsentChange(e.target.checked)}
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
/>
<span className="text-label text-text-primary font-normal leading-normal">
+17 -12
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, handleConsentChange, status, error, successMessage, emailRef, handleEmailChange, handleEmailBlur, handleSubmit } =
useNewsletterSignup("newsletter-modal");
// Background scroll lock while open — intercepts and cancels the wheel/
@@ -186,13 +186,11 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
</p>
<p className="text-body text-text-primary">
Melde dich zum Newsletter an und erhalte die 7-Tage-Challenge, mit der du durch mehr Struktur weniger Stress spürst.
Melde dich zum Newsletter an und bekommst 7 kurze Impulse direkt ins Postfach in ein paar Minuten gelesen, sofort im Alltag umsetzbar.
</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">
@@ -209,9 +207,13 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
emailError ? "border-red-600 focus:border-red-600" : "border-border focus:border-brand"
}`}
/>
{emailError && (
<p className="text-label text-red-600 font-normal -mt-2">{emailError}</p>
)}
{/* Always rendered (min-h reserves one line's worth of
space) rather than conditionally mounted — this sits
inside the same row the photo on the left stretches
to match (items-stretch, md:aspect-auto), so an error
popping in and out used to grow/shrink the whole
modal, visibly resizing the photo along with it. */}
<p className="text-label text-red-600 font-normal -mt-2 min-h-[1.05rem]">{emailError}</p>
<button
type="submit"
disabled={status === "submitting"}
@@ -225,7 +227,7 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
type="checkbox"
required
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
onChange={(e) => handleConsentChange(e.target.checked)}
className="size-4 shrink-0 rounded-xs border border-border accent-brand"
/>
<span className="text-label text-text-primary">
@@ -241,9 +243,12 @@ export function NewsletterModal({ open, onClose }: { open: boolean; onClose: ()
.
</span>
</label>
{status === "error" && (
<p className="text-label text-red-600 font-normal">{error}</p>
)}
{/* Same reserved-space fix as emailError above — this is
the "already subscribed" message, the one that actually
prompted it. */}
<p className="text-label text-red-600 font-normal min-h-[1.05rem]">
{status === "error" ? error : ""}
</p>
</form>
)}
</div>
+2 -2
View File
@@ -20,13 +20,13 @@ export async function Tools() {
{/* Section header */}
<Reveal className="flex flex-col gap-2 items-start px-[var(--layout-padding-x)] w-full">
<p className="font-bold text-brand text-h-small">
Meine Werkzeuge
Werkzeuge
</p>
<p
className="font-semibold text-text-primary text-h-section"
style={{ fontFamily: "var(--font-lora)" }}
>
Werkzeuge für einen leichteren Alltag.
Werkzeuge für mehr Orientierung im Alltag
</p>
</Reveal>
+6
View File
@@ -16,6 +16,9 @@ const VALID_TYPES: EmailTemplateType[] = [
"order-cancelled",
"order-return-requested",
"order-returned",
"order-tracking-added",
"order-tracking-corrected",
"order-delivered",
];
const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
@@ -23,6 +26,9 @@ const STATUS_TYPE_FALLBACK_HEADING: Record<string, string> = {
"order-cancelled": "Deine Bestellung wurde storniert",
"order-return-requested": "Deine Rücksendung wurde angefragt",
"order-returned": "Deine Retoure wurde bearbeitet",
"order-tracking-added": "Hier ist deine Sendungsnummer",
"order-tracking-corrected": "Korrigierte Sendungsnummer",
"order-delivered": "Dein Paket ist angekommen",
};
// Entered exclusively via EmailTemplates.ts's admin.livePreview.url (a
@@ -33,7 +33,7 @@ export default async function KontoBestellungDetailPage({ params }: { params: Pr
const session = await getSessionCustomer();
if (!session) redirect("/konto/login");
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber));
const order = await getCustomerOrderDetail(session.token, session.customer.id, decodeURIComponent(orderNumber), true);
if (!order) notFound();
const address =
+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}` };
+40 -3
View File
@@ -416,9 +416,33 @@ export type CustomerOrder = {
productIds: number[];
};
export async function getCustomerOrders(token: string, customerId: number): Promise<CustomerOrder[]> {
// Excludes orders that never actually happened from the customer's own
// point of view — a `pending_payment` order whose Stripe payment failed
// (or timed out, see the backend's expirePendingPayments job) transitions
// straight to `cancelled` without ever getting an `invoiceNumber`
// (deferred until payment confirms, see confirmPayment.ts). A *real*
// cancellation (Storno) is always of an already-`received`, already-
// invoiced order, so `invoiceNumber` is always present there. That
// distinction — `status: 'cancelled'` with no `invoiceNumber` — is what
// separates "a real order that got cancelled" (show it) from "a checkout
// attempt whose payment never went through" (nothing to show — the row
// stays in Payload for admin/audit purposes, just not surfaced here).
function excludeFailedPaymentAttemptsQuery(): Record<string, string> {
return {
"where[and][1][or][0][status][not_equals]": "cancelled",
"where[and][1][or][1][invoiceNumber][exists]": "true",
};
}
// `excludeFailedPaymentAttempts` defaults to true (list views) — the one
// exception is /api/account/export/route.ts's GDPR data export, which
// passes false: a legal completeness export must include every order
// row that exists about this customer, not just the ones normally shown
// in "Meine Bestellungen".
export async function getCustomerOrders(token: string, customerId: number, excludeFailedPaymentAttempts = true): Promise<CustomerOrder[]> {
const params = new URLSearchParams({
"where[customer][equals]": String(customerId),
"where[and][0][customer][equals]": String(customerId),
...(excludeFailedPaymentAttempts ? excludeFailedPaymentAttemptsQuery() : {}),
sort: "-createdAt",
depth: "0",
limit: "50",
@@ -508,10 +532,23 @@ export type CustomerOrderItem = {
// caller round-trip a full, valid items array back on a return request
// (Orders.ts's field-lock hook needs every required item field present,
// not just returnQuantity — see that hook's own comment).
export async function getCustomerOrderDetail(token: string, customerId: number, orderNumber: string): Promise<CustomerOrderDetail | null> {
// `excludeFailedPaymentAttempts` defaults to false because this function
// is shared with /api/checkout/status/route.ts's polling right after a
// Stripe payment fails — that flow needs to keep seeing the
// `cancelled`/no-`invoiceNumber` order (to show "Zahlung fehlgeschlagen,
// bitte erneut versuchen") for exactly the same order this flag would
// otherwise hide. Only /konto/bestellungen/[orderNumber] (a customer
// browsing their own history, not mid-checkout) opts in.
export async function getCustomerOrderDetail(
token: string,
customerId: number,
orderNumber: string,
excludeFailedPaymentAttempts = false,
): Promise<CustomerOrderDetail | null> {
const params = new URLSearchParams({
"where[orderNumber][equals]": orderNumber,
"where[customer][equals]": String(customerId),
...(excludeFailedPaymentAttempts ? excludeFailedPaymentAttemptsQuery() : {}),
depth: "0",
limit: "1",
});
+38
View File
@@ -66,6 +66,31 @@ 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;">
<p style="margin:0 0 8px;">Bitte überweise den Rechnungsbetrag unter Angabe der Bestellnummer ${escapeHtml(orderNumber)} auf ${bankLine ? "folgende Bankverbindung:" : "die dir genannte Bankverbindung."}</p>
${bankLine ? `<p style="margin:0 0 12px;font-weight:700;color:${TEXT_PRIMARY};">${escapeHtml(bankLine)}</p>` : ""}
<p style="margin:0;">Deine Bestellung wird nach Zahlungseingang bearbeitet (in der Regel innerhalb von 12 Werktagen).</p>
</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 +206,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 +235,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 +297,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));
@@ -278,6 +313,9 @@ export const ORDER_STATUS_EMAIL_ICON: Record<string, string> = {
"order-cancelled": "✕",
"order-return-requested": "↩",
"order-returned": "✓",
"order-tracking-added": "📦",
"order-tracking-corrected": "📦",
"order-delivered": "🎉",
};
export function renderOrderStatusHtml(
+8 -1
View File
@@ -53,7 +53,14 @@ export type OrderConfirmationEmailData = OrderConfirmationData & {
// fails (still alerted, same severity as the frontend's own critical-error
// path for this checkout flow).
export async function sendOrderConfirmationEmail(order: OrderConfirmationEmailData, customerEmail: string): Promise<boolean> {
const template = (await getEmailTemplate("order-confirmation")) ?? {
const fetchedTemplate = await getEmailTemplate("order-confirmation");
// `active === false` is a deliberate admin decision to suppress this
// email entirely — distinct from `fetchedTemplate` being null (no row
// saved yet), which still sends below with the hardcoded default
// wording. Checked before the fallback is applied, since the fallback
// object has no `active` field of its own (implicitly always on).
if (fetchedTemplate && !fetchedTemplate.active) return false;
const template = fetchedTemplate ?? {
subject: "Bestellt! Deine Ruhe kann kommen 🎉",
heading: "Bestellt!",
bodyText: "Deine Bestellung ist bei uns eingetrudelt — wir kümmern uns schon liebevoll darum, sie für dich zu packen.",
+32 -4
View File
@@ -638,9 +638,8 @@ export type CheckoutPaymentOption = PaymentMethod & { hint?: string };
// `manual` rows (Überweisung) pass through unchanged — one real gateway
// there, one option, nothing to collapse.
export function groupPaymentMethodsForCheckout(methods: PaymentMethod[]): CheckoutPaymentOption[] {
const manual = methods.filter((m) => m.provider !== "stripe");
const stripeMethods = methods.filter((m) => m.provider === "stripe");
if (stripeMethods.length === 0) return manual;
if (stripeMethods.length === 0) return methods;
const combinedIcons = Array.from(new Set(stripeMethods.flatMap((m) => m.icons)));
const online: CheckoutPaymentOption = {
@@ -650,7 +649,27 @@ export function groupPaymentMethodsForCheckout(methods: PaymentMethod[]): Checko
provider: "stripe",
hint: "Kreditkarte, PayPal & weitere Methoden — die genaue Zahlungsart wählst du im nächsten Schritt.",
};
return [...manual, online];
// Preserve `methods`' own order (already sortOrder-sorted by the fetch)
// instead of hardcoding manual-first — a real bug: "Online-Zahlung" had
// a lower sortOrder than "Überweisung (Vorkasse)" in the admin, but
// this function always put manual rows first regardless, so the
// checkout showed them in the wrong order. Splice the combined entry in
// at the position of the *first* stripe row encountered, drop any
// further stripe rows (already folded into `online`).
const result: CheckoutPaymentOption[] = [];
let onlineInserted = false;
for (const m of methods) {
if (m.provider === "stripe") {
if (!onlineInserted) {
result.push(online);
onlineInserted = true;
}
continue;
}
result.push(m);
}
return result;
}
export type WerkzeugeCard = {
@@ -798,7 +817,10 @@ export type EmailTemplateType =
| "order-shipped"
| "order-cancelled"
| "order-return-requested"
| "order-returned";
| "order-returned"
| "order-tracking-added"
| "order-tracking-corrected"
| "order-delivered";
type PayloadEmailTemplate = {
type: EmailTemplateType;
@@ -806,6 +828,11 @@ type PayloadEmailTemplate = {
heading: string;
bodyText: string;
footerText: string | null;
// false means the admin deliberately suppressed this email — checked
// by orderEmail.ts before sending order-confirmation, never treated as
// "row missing, use hardcoded default" (that's what a null return from
// this function itself already means).
active: boolean;
};
// draft:true is used by app/email-preview/[type]/page.tsx (Live Preview,
@@ -863,6 +890,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).
+35 -1
View File
@@ -9,6 +9,16 @@ 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.
// Per the user's own explicit wording request, see the newsletter-DOI
// memory — kept here once rather than duplicated across all 4 forms.
const SUCCESS_MESSAGE = "Fast geschafft! Schau kurz in dein Postfach da wartet schon eine Mail von uns.";
// Deliberately routed through the *error* state, not a success variant —
// per explicit feedback: swapping the whole form out for a bare message
// (the real-success treatment) felt wrong for "you're already signed up,
// nothing to do" — the form should stay visible, with a small note below
// it, exactly like every other inline validation error already does.
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("");
@@ -17,15 +27,34 @@ export function useNewsletterSignup(source: NewsletterOptInSource) {
const [error, setError] = useState("");
const emailRef = useRef<HTMLInputElement>(null);
// Clears a previous submit-time error (real failure or "already
// subscribed") the moment the customer interacts with the form again —
// same "stale validation message shouldn't linger" behavior
// emailError already had for itself, extended to the submit-result
// error too, since it's otherwise easy to misread as still describing
// the current (possibly already-corrected) input.
function clearSubmitError() {
if (status === "error") {
setStatus("idle");
setError("");
}
}
function handleEmailChange(value: string) {
setEmail(value);
if (emailError) setEmailError("");
clearSubmitError();
}
function handleEmailBlur(value: string) {
setEmailError(validateEmailFormat(value));
}
function handleConsentChange(checked: boolean) {
setConsent(checked);
clearSubmitError();
}
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const formatError = validateEmailFormat(email);
@@ -48,6 +77,11 @@ export function useNewsletterSignup(source: NewsletterOptInSource) {
setStatus("error");
return;
}
if (data.alreadySubscribed) {
setError(ALREADY_SUBSCRIBED_MESSAGE);
setStatus("error");
return;
}
setStatus("success");
} catch {
setError("Anmeldung ist fehlgeschlagen. Bitte versuche es später erneut.");
@@ -55,5 +89,5 @@ export function useNewsletterSignup(source: NewsletterOptInSource) {
}
}
return { email, emailError, consent, setConsent, status, error, emailRef, handleEmailChange, handleEmailBlur, handleSubmit };
return { email, emailError, consent, handleConsentChange, status, error, successMessage: SUCCESS_MESSAGE, 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, handleConsentChange, 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
@@ -157,7 +155,7 @@ export function WeeklyImpulsesHero() {
type="checkbox"
required
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
onChange={(e) => handleConsentChange(e.target.checked)}
className="size-4 shrink-0 mt-0.5 rounded-xs border border-border accent-brand"
/>
<span className="text-label text-text-primary font-normal leading-normal">
+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.2",
"resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#ceaa437724dae1adfd582ca209300d2ee0a10e68",
"version": "0.2.6",
"resolved": "git+https://git.mk360.de/Marco/einfach-produktiv-invoicing.git#d6e83db5cf6343e65e2fa25f9ead49fce9a5a308",
"dependencies": {
"@e-invoice-eu/core": "^3.1.1"
},