Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94cba756c8 | |||
| b835e8622f | |||
| 7286c76787 | |||
| e175d2b4c3 | |||
| 1306dce6e4 | |||
| 8fa4a20e63 | |||
| 1d875a65c0 | |||
| 0436e87147 | |||
| 7feda76b3c | |||
| c4fb6586e7 | |||
| 3dccc229af | |||
| 6c43171b11 | |||
| 8d4ee75cfb | |||
| 8a910f7ff8 | |||
| a7fa0ec027 | |||
| 0ac2e45077 | |||
| fd98f68ba6 | |||
| bccfc28012 | |||
| 55df2e104c | |||
| 1706da8598 | |||
| 1dbc0c31ff | |||
| 06a00d67e4 | |||
| 952b902702 | |||
| f959ebb998 | |||
| bae23775f2 | |||
| 0e12ab1f1e | |||
| cb4c9d77f2 |
@@ -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 2–6 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 1–2 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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
});
|
||||
|
||||
@@ -66,6 +66,31 @@ function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// 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 1–2 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(
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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).
|
||||
@@ -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
@@ -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 />
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user