Commit Graph

52 Commits

Author SHA1 Message Date
Marco 740b791e5e Add Stripe payment processing (cards + PayPal) with a webhook-gated checkout flow
Checkout now branches on payment-methods.provider: Überweisung stays
immediate/unchanged, Kreditkarte/PayPal creates a pending_payment order,
mounts Stripe's Payment Element, and defers invoice/email to a webhook-
verified confirm-payment call once the backend actually confirms payment.
Includes a PAYMENT_TEST_MODE mock provider so the whole gated pipeline is
exercisable locally without a real Stripe account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-25 12:03:04 +00:00
Marco 797d9d42fe Custom post content blocks (images/gallery/video/quote) + backend-driven SEO settings
RichText.tsx switched to Payload's official React renderer + custom
JSXConverters (same call signature, LiveRichText/LivePostContent
untouched) — needed to render the new Lexical Blocks the Payload repo's
Posts.content just gained. Converters follow the existing CMS-image
convention (relative + aspect-[...] + fill + object-cover); the video
block resolves YouTube/Vimeo links to an iframe embed.

New getSeoSettings() fetcher (same pattern as getKleinunternehmer()),
app/layout.tsx now generateMetadata() reading it with the same fallback
values it used to hardcode. Per-post SEO overrides (seoTitle/
seoDescription/seoImage) wired into the blog detail page's metadata,
falling back to title/excerpt/thumbnail when empty.

Also fixed while auditing every page's metadata: missing descriptions on
3 konto pages, a static title on the dynamic order-detail route, and
missing OG images on /shop and /blog.
2026-07-24 21:10:33 +00:00
Marco ba830947d2 Add Kleinunternehmerregelung (§19 UStG) support end-to-end
Checkout forces 0% VAT without de-grossing prices when the tenant is a
Kleinunternehmer (a business decision, not just an engineering default —
unlike the existing intra-community VAT exemption, which does de-gross).
Snapshotted onto the order at checkout time so a later toggle of the
company-settings checkbox never rewrites an already-issued invoice's tax
treatment — same reasoning as the existing vatExempt field.

Threaded through: checkout route, order creation/confirmation email,
on-demand invoice/Storno/Gutschrift downloads, the Bestellbestätigung
page, and the account order-detail page. The four storefront "inkl. X%
MwSt." price hints (shop grid, cart upsell, ToDo-Karten landing page,
homepage spotlight) drop that clause live when the setting is on. The
company-settings Live Preview reflects the checkbox in real time too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 15:08:13 +00:00
Marco 19f6559c29 Fix VIES check treating "member state unavailable" as "invalid"
VIES answers HTTP 200 even when it couldn't actually perform the check
(actionSucceed: false, e.g. MS_UNAVAILABLE — Germany's own national
gateway does this fairly regularly). checkVatIdViaVies() only ever read
data.valid, which is absent on that response shape, so it silently
read as valid: false — a real, currently-registered German VAT ID
(reported: DE351362947) looked rejected. Worse, /api/checkout/validate-
vat then wrapped even a correctly-returned ok:false as { ok: true,
valid: false }, which the client reads as "invalid" rather than
"unavailable" — the actual bug the user hit, compounding the vies.ts
gap. Both are fixed now: an unconfirmable check surfaces to the client
as ok:false, which CheckoutContent.tsx's handleVatIdBlur already
correctly renders as "USt-IdNr.-Prüfung derzeit nicht möglich"
instead of a rejection. Same fix applied to the payload backend's own
copy of vies.ts (company-settings' VAT check).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 21:21:46 +00:00
Marco 789a818c6b Add on-blur email validation to every newsletter signup form
Extends the checkout pattern (inline red error text, refocus on
submit if invalid) to all four newsletter-signup entry points. Two of
them (WeeklyImpulsesHero's inline hero form on /newsletter, and
/challenge's EmailCapture) turned out to be completely non-functional
before this too — same static-markup-with-no-onSubmit issue as
Newsletter.tsx/NewsletterModal.tsx had, just missed in the previous
pass since they're separate components sharing only the visual
pattern, not the code.

Consolidated the shared email+consent+submit state (previously
duplicated per-component) into useNewsletterSignup.ts, and pulled the
plain email-format regex (previously duplicated in CheckoutContent.tsx
and the subscribe route) into lib/email.ts as a single source of
truth. /challenge's EmailCapture is now its own client component
(app/challenge/components/EmailCapture.tsx) since its parent page is
an async Server Component and can't hold form state itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 20:48:13 +00:00
Marco 6a4539bf9b Sync newsletter opt-ins to Brevo
Both standalone signup forms (Newsletter.tsx on Home/newsletter page,
NewsletterModal.tsx from the Navbar CTA) were previously non-functional
— static markup with no onSubmit/state at all, nothing was ever
captured. They're now real client forms posting to the new
/api/newsletter/subscribe route, which upserts the contact into
Brevo's Contacts API (list id from BREVO_LIST_ID). Checkout's existing
newsletterOptIn checkbox gets the same sync, fire-and-forget alongside
the order-confirmation email — a failed marketing sync must never
fail checkout.

lib/brevo.ts is the only thing that talks to Brevo; this app still
never sends marketing mail itself. Whatever automation Brevo has
configured on the list (Welcome Flow etc.) runs entirely on their
side — Brevo's Automation workflows aren't manageable via their
public API at all, so that part can't be wired up from here.

Needs BREVO_API_KEY and BREVO_LIST_ID set in the frontend's Coolify
environment — not yet added there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 20:38:41 +00:00
Marco 47d03dd61b Feed checkout's country selects from Payload instead of a hardcoded list
Both the billing and shipping-override country selects, plus PLZ
maxLength/pattern validation, now read from the new shipping-countries
collection (getShippingCountries()) rather than a hardcoded
Deutschland/Österreich(/Schweiz) array. Lets an admin add or reorder
destination countries without a frontend deploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 20:21:13 +00:00
Marco 6802636d1d Add innergemeinschaftliche-Lieferung VAT exemption for cross-border B2B
A validated EU business buyer (Österreich, the one cross-border option
this checkout offers) gets the sale zero-rated per §4 Nr. 1b UStG —
but only after a live VIES lookup confirms the VAT ID is actually
registered right now, never from format-validity alone (real
compliance risk otherwise). VIES unreachable fails closed: normal VAT
applies, no guessed exemption.

- lib/vies.ts: calls the EU's public VIES REST API.
- lib/vatExemption.ts: de-grosses item/shipping prices and computes
  the exempt totals; also picks the actual destination country
  (shipping override when set, billing otherwise).
- api/checkout/validate-vat: on-blur live check for instant feedback;
  api/checkout/route.ts re-runs the same check server-side at submit
  as the actual source of truth, and re-prices every line net-of-VAT
  when exempt.
- CheckoutContent.tsx: VIES status + a live exempt-totals preview;
  BestellbestaetigungContent.tsx mirrors it from the persisted
  snapshot. Both blur-validate every other checkout field now too
  (immediate inline errors, not just on submit).
- vatExempt/vatIdValidatedAt threaded through orderServer.ts,
  customerAuth.ts, orderEmail.ts, and both invoice-download routes so
  the invoice PDF and its e-invoice XML (companion payload-repo
  commit) reflect the exemption correctly wherever it's rendered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 19:10:01 +00:00
Marco e48107470a Add optional Firma/USt-IdNr. fields to checkout and profile
B2B checkout fields, split out from the e-invoicing migration and
picked back up now that it's shipped. Both fields are independently
optional, format-validated (shared regex in lib/vatId.ts, mirrored
server-side in api/checkout and api/account/profile), persisted in
the checkout draft, and saved as a customer profile default that
pre-fills future checkouts. Order/customer snapshot fields land in a
companion Payload backend commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 17:53:21 +00:00
Marco 2d88fb86a1 Cap add-to-cart quantity at actual remaining stock
Stock was only checked at checkout; a shopper could add more of a
product to the cart than was actually in stock and only find out at
the last step. Product/variant now carry a real maxQty, and
AddToCartButton/AddToCartInlineButton/the cart's quantity stepper all
disable or cap once the cart already holds that many.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 17:53:14 +00:00
Marco b5ad13cf43 Revert generalPartners, fix shareCapital: neither is a Pflichtangabe
Corrected after user feedback: Stammkapital/Grundkapital is only required on business correspondence if voluntarily disclosed in the first place (§35a Abs. 1 S. 2 GmbHG) — not an unconditional Pflichtangabe. shareCapital's Impressum rendering stays (shown only if an admin voluntarily filled it in), but it's dropped from the email/invoice footer.

generalPartners is removed entirely — the legal basis was genuinely unclear on research (§125a HGB's Geschäftsbriefe-naming duty only applies to the narrow case where no partner is a natural person; whether §5 DDG's Impressum-specific "vertretungsberechtigte Person" requirement independently mandates it for the general OHG/KG case wasn't resolved with confidence) — reverted rather than shipped on an uncertain legal basis.

Also fixes stale "§5 TMG" citations to "§5 DDG" (TMG was replaced 14 May 2024).
2026-07-23 14:12:35 +00:00
Marco a3912a47c4 Model Stammkapital/Grundkapital and Gesellschafter for the not-yet-needed legal forms
Mirrors the backend's new CompanySettings.shareCapital/generalPartners fields: rendered in the Impressum (AnbieterAngaben.tsx — new "Gesellschafter" section, Stammkapital line under Handelsregister, and the "Verantwortlich für den Inhalt" fallback now considers a general partner before falling back to sellerName) and wired into buildLegalFooterLines() for the invoice/email footer, same as registerCourt/registerNumber/managingDirector already were.

No visible change today (current legalForm is sole-proprietorship, neither field is set) — this is prep so a future legalForm change in company-settings updates the Impressum automatically instead of needing a manual Impressum edit at that point.
2026-07-23 13:26:33 +00:00
Marco b70aefd5cc Phase 3: wire e-invoice generation into checkout email + download routes
invoiceData.ts's generateInvoicePdf()/generateCorrectionInvoicePdf() now
call renderInvoiceEInvoice()/renderCorrectionInvoiceEInvoice() instead of
the plain PDF renderers — both are the single wrapper every caller
already goes through (orderEmail.ts's checkout attachment, and the two
on-demand /invoice and /correction-invoice download routes), so this one
change switches all three. Buffer.from() wraps the library's Uint8Array
return value — every downstream consumer already expects a Buffer,
unchanged.

Imports from "@einfach-produktiv/invoicing/einvoice" (a new subpath, not
the package's main entry) — @e-invoice-eu/core pulls in Node-only
dependencies that broke the client bundle when reachable from the main
entry, which a Client Component also imports transitively (Live
Preview). See that package's own commit for the fix.

Existing failure-handling is unchanged and covers this: a PDF-generation
error still doesn't block the confirmation email, it just sends without
the attachment and alerts admin (see orderEmail.ts) — same safety net
that already existed for the plain-PDF path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 11:32:46 +00:00
Marco 05a3b009d3 Phase 2: iban/bic instead of bankDetails, matching the backend collection
CompanySettings type now mirrors the payload repo's split bankDetails ->
iban/bic (see that repo's own commit for the reasoning). No behavior
change here beyond the type/fallback update — the actual footer
rendering lives in @einfach-produktiv/invoicing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 10:20:17 +00:00
Marco 66ac184a6f Move invoice/tax-breakdown PDF generation into @einfach-produktiv/invoicing
Phase 0 of the e-invoicing migration plan (see the E-Rechnung planning
session) — moves invoicePdf.tsx, correctionInvoicePdf.tsx, and
taxBreakdown.ts into a new shared package, consumed as a git dependency
by both this repo and the payload backend, instead of hand-duplicating
the correction-invoice logic between them (see that package's own README
for the three real drifts the duplication had already caused).

Consumed as raw TS/TSX source via next.config.ts's transpilePackages, not
a pre-built package. Needs `git` in the Docker deps stage and a
project .npmrc (allow-git=root) to let npm ci fetch a git-URL dependency
at all — npm 12+ disables that by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-23 09:18:20 +00:00
Marco a50524832e Move low-stock hint to badge, right-align VAT breakdown, gate cart discount field, split billing/shipping delivery method
- Removed the inline "Nur noch wenige verfügbar" text hint from
  AddToCartButton/AddToCartInlineButton (was making card heights vary in
  every grid that renders them — RelatedProducts, ProductSpotlight's CTA
  row) — now only shown via the same image-overlaid pill badge
  Ausverkauft/discount already use (position: absolute, doesn't affect
  layout). Added that badge to RelatedProducts.tsx and todo-cards'
  Pricing.tsx, which didn't have it before.
- RelatedProducts cards now also show "inkl. X% MwSt." (was missing
  entirely)
- VatBreakdown rows are now flex rows with a spacer instead of plain
  text, so every € amount right-aligns to the same edge regardless of
  how many digits the rate itself has (was visibly staggered with mixed
  7%/19% rates)
- Cart's manual discount-code field only renders when Payload actually
  has at least one active code right now (lib/discountServer.ts's new
  hasActiveDiscountCode()) — no point showing an open field that could
  never validate. An already-applied code (e.g. from an older session)
  still always shows its own result row regardless.
- Checkout's "1. Rechnungsadresse" no longer offers a Packstation option
  — a Packstation isn't a valid billing address for an invoice. Only a
  plain street address now; Packstation is only offered on the separate,
  optional "Abweichende Lieferadresse" section, which already had its own
  address/Packstation toggle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 23:25:18 +00:00
Marco 43944d8cc8 Fix navbar/discount/invoice bugs from manual QA, add VAT breakdown, shipping-address override, checkout persistence, redesigned mobile menu
Bug fixes:
- Navbar login/logout state now updates immediately (custom ep-auth-changed
  event) instead of requiring a hard reload
- Status-change email links were broken by an un-encoded "#" in the order
  number; fixed for all 4 status emails
- Cart discount code: manual input field restored (was removed entirely)
- Quote-label underline now scales with the label's actual text width
- Number Ranges admin list now shows the invoice prefix/counter columns

Pricing & VAT:
- Prices show the real per-product VAT rate ("inkl. X% MwSt.") instead of
  a generic disclosure
- Cart/checkout/confirmation totals show the actual € amount of VAT
  included, broken down per rate when a cart spans more than one
  (new lib/taxBreakdown.ts, shared with the invoice PDF's own math)
- Account order pages gained product thumbnails and the same VAT breakdown

Low-stock warning: a "Nur noch wenige verfügbar" badge/hint across the
shop grid, spotlight, and add-to-cart variant pickers, driven by the
existing lowStockThreshold field (still never exposes raw stock counts).

Invoice PDFs: product thumbnails on every line item, a plain "Netto"
label (rate was redundant, already stated on the MwSt. line below), no
more duplicate USt-IdNr. in the header, and — for a Stornorechnung
specifically — an explicit "Versand" line that was previously only
folded silently into the tax totals.

Checkout:
- Optional deviating shipping address (separate from the billing address
  used for the invoice), with its own toggle + address form
- Full checkout draft persistence (name/address/shipping/payment
  selections) survives navigating away and back, via localStorage
- Invoice PDF shows a third "Lieferadresse" block when the shipping
  address differs from billing

Mobile navigation: fullscreen panel with a circular reveal animation from
the hamburger's corner, replacing the old in-flow accordion drawer; no
login CTA inside it (redundant with the always-visible header icon).

Admin-facing (Payload backend, mirrored where the frontend has a ported
copy of the same renderer): dashboard rebuilt as individual cards, split
into 3 task queues (received/processing/returns) instead of 2, revenue
and order counts now exclude cancelled/returned orders immediately, and
the low-stock alert links to the specific affected product(s) instead of
the unfiltered list. A new immediate email notifies the shop owner the
moment an order comes in, instead of only via the daily digest.

Testimonials admin list now groups by page instead of interleaving all
three grids' entries. ~45 English admin field descriptions translated to
German for consistency.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 22:52:15 +00:00
Marco 39782eeab9 Out-of-stock UI, variant picker on marketing pages, server-side stock check
- ProductGrid/AddToCartInlineButton/AddToCartButton now show "Ausverkauft"
  and disable add-to-cart per variant (or product-level with no variants),
  derived from trackInventory/stock/allowBackorder via isOutOfStock().
- AddToCartButton (todo-cards Hero+Pricing, homepage spotlight) gains the
  same variant <select> AddToCartInlineButton already had — all three call
  sites already fetch full product data server-side.
- /api/checkout re-validates stock server-side (depth-in-defense, not just
  the disabled button), rejecting when trackInventory is on, allowBackorder
  is off, and requested qty exceeds stock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PL4zfTY1sXc8x5QS6FatM
2026-07-22 17:58:10 +00:00
Marco c5500bcc97 Wire up product variants end-to-end, add tracking-number display
Completes the frontend half of the Payload backend's variant/inventory/
tracking work (see that repo's own commit):

- **Cart**: CartItem gained an optional `variant?: string` field — every
  function that used to match a line by `id` alone (addToCart/
  removeFromCart/setQuantity) now matches by `(id, variant)` together via
  a shared sameLine() helper, so two lines for the same product with
  different variants stay separate entries. `variant` undefined on both
  sides (the no-variants case) still matches by simple equality, so every
  pre-existing call site keeps working unchanged.
- **Selection UI**: AddToCartInlineButton renders a <select> above the
  button when given a non-empty `variants` prop (ProductGrid/
  RelatedProducts pass product.variants straight through); defaults to
  the first variant.
- **Pricing**: cartTotals.ts's new effectivePrice(entry, product) — a
  variant's priceOverride wins over the base product price. Every cart/
  checkout/order-confirmation total and per-line price display now goes
  through this instead of reading product.price directly (fixes both a
  wrong-price bug and a duplicate-React-key bug the old `key={product.id}`
  pattern would have had the moment two variants of one product were both
  in the cart).
- **Checkout**: re-validates the requested variant server-side (same
  "never trust the client" reasoning as price re-derivation) — a variant
  name that doesn't exist on that product fails the whole checkout.
  variantName snapshots onto orders.items, shown as a parenthetical next
  to the product name on the confirmation email, both invoice PDF types,
  and the order-detail page.
- **Cross-device cart**: Customers.cart[].variantName (synced via
  /api/account/cart) carries the selection through a login/logout cycle,
  not just the current session.

Also adds tracking-number display: /konto/bestellungen/[orderNumber]
shows a clickable link when orders.trackingNumber is set, built by a new
app/lib/tracking.ts that mirrors the Payload backend's own copy
byte-for-byte close (same carrier set/URL patterns) so what a customer
sees here matches exactly what the order-shipped email already links to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 17:43:47 +00:00
Marco a935357e70 Fix Live Preview footer placeholder email to not look like a real hardcoded address
DEFAULT_LEGAL_FOOTER_LINES' email was "admin@mk360.de" — the same domain
as the real send address, which read as a hardcoded real value in the
preview rather than an obvious placeholder. Now "kontakt@musterfirma.de",
matching "Musterstraße 12"'s already-fake spirit. Mirrors the same fix
already made on the Payload backend's copy of this constant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 15:44:36 +00:00
Marco 07c70c86f5 Add legalForm-driven Pflichtangaben (register court/number, managing director)
Mirrors the Payload backend's new company-settings.legalForm field: when
present, registerCourt/registerNumber/managingDirector now appear in the
email footer (buildLegalFooterLines) and both invoice PDF footers,
matching §37a HGB / §35a GmbHG requirements for registered legal forms.
A sole proprietorship (the default) renders identically to before —
these fields are only appended when actually set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 15:09:43 +00:00
Marco d7e7928dfc Set Reply-To to sellerEmail, make From display name dynamic, fix stale footer docs
Customer replies to order-confirmation and resend-verification mail now
route to the seller's real address via Reply-To, and the From display
name reflects sellerName — but the From address itself stays
admin@mk360.de since sellerEmail's domain isn't confirmed SPF-authorized
on the Hostinger account yet (see the "SMTP From address pending SPF"
memory note for the follow-up). Also cleans up README references left
over from the previous footer rewrite (stale "company line" wording, a
dangling cross-reference to a renamed section).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 14:38:01 +00:00
Marco e61a62e579 Give every email a full legal footer (Anbieterkennzeichnung), not just a company line
Order confirmation, resend-verification, and the internal critical-alert
mail now render name, street, ZIP/city, email, and VAT ID from
company-settings instead of a bare "<sellerName> · <sellerEmail>" line,
so every email this app sends meets business-correspondence footer
requirements rather than just the customer-facing ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 14:15:43 +00:00
Marco 51fee198f4 Add a Vitest unit test suite (cart totals, invoice tax grouping, bundle contents)
No test infrastructure existed in this repo yet. Covers the pure logic
most likely to silently produce wrong numbers on a live order: discount/
shipping math, per-rate invoice grouping, and bundle-contents string
building. Extracted describeBundleContents() out of the checkout route
into its own module so it's importable from a test (route.ts files only
allow HTTP-method exports).
2026-07-22 11:40:31 +00:00
Marco 91f6fef6ea Support partial returns — per-item quantity, item-only Gutschrift (no shipping refund, no discount reproration)
Customers can now select which items and how many units to return
instead of only the whole order. The Gutschrift reflects only the
returned quantities, excludes shipping (already delivered), and leaves
the original discount untouched — confirmed policy, not an engineering
default. Stornorechnung (pre-shipping cancellation) is unaffected and
stays a full reversal including shipping.
2026-07-22 11:30:46 +00:00
Marco e50d43ea44 Rename invoice-settings to company-settings, add its own Live Preview, and refine invoice PDF layout
Company data now has its own Payload admin group and a live in-browser
PDF preview (react-pdf's PDFViewer) instead of just a plain settings
form. Invoice header is a brand-colored rule instead of a filled band,
and the footer is now pinned to the page bottom instead of following
content flow.
2026-07-22 11:11:58 +00:00
Marco 179b59d73d Redesign invoice PDFs, add correction-invoice downloads, return reasons, and per-product tax/bundle support
Invoice + Stornorechnung/Gutschrift PDFs get a modern header-band layout,
a "bereits beglichen" badge for immediately-paid orders, labelled bank
details, and a per-tax-rate summary breakdown. Correction invoices can
now be re-downloaded from the account (regenerated deterministically,
not stored as files, same approach as the original invoice). Return
requests capture a reason. Products can define bundles (bundleItems) and
a per-product VAT rate override, both snapshotted onto order items.
2026-07-22 10:31:22 +00:00
Marco 5232b14cdf Generate invoice PDFs attached to order confirmation, and send emails on order status changes
Invoice PDFs (§14 UStG line items, tenant-configurable VAT rate) are now
generated at checkout and attached to the confirmation email, plus
available on demand from the order-detail page. Payload-side, orders now
also email the customer on shipped/cancelled/return_requested/returned,
with Stornorechnung/Gutschrift correction PDFs attached for the latter two
so the original invoice's immutable number stays honest.
2026-07-22 09:49:56 +00:00
Marco fa02d95dff Make checkout's login prompt reactive instead of persistent, and give the confirmation email real style and voice
The always-visible "Schon Kundin?" toggle was gendered and shown to every
logged-out visitor regardless of relevance. Card 1's email field now
checks on blur (/api/account/check-email) whether that address already
has an account, and only then swaps in a gender-neutral login form,
pre-filled — the collision check in handleSubmit stays as a fallback.

The order-confirmation and password-reset emails also got a real visual
pass: same warm background/brand color/circular success-icon treatment as
the on-screen /bestellbestaetigung page, serif heading, thin brand
divider, table-based layout for email-client compatibility. Copy is
on-brand and a little playful now instead of generic transactional
boilerplate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 08:38:41 +00:00
Marco ec75a480bd Detect checkout email collisions and show login state in the navbar
Registering with an email that already has an account previously just
failed with a generic error and no clear next step. registerCustomer()
now flags emailExists specifically, and checkout switches straight to the
login toggle (email pre-filled, scrolled into view) instead. The account
icon also gets a small underline while logged in, matching the nav links'
active-state styling — it was otherwise the only nav element that gave no
visual signal of session state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 08:26:00 +00:00
Marco f0df359db4 Add password reset, order confirmation email with editable templates, and fix missing account entry points
Password reset uses Payload's built-in forgot/reset-password flow,
customized to link to this app instead of the Payload admin. Order
confirmation email and the password-reset email's wording both come from
a new Payload email-templates collection, editable without a deploy and
previewable via Live Preview at /email-preview/[type] (same mechanism as
Posts/LegalPages/Testimonials, sample data instead of a real document).

Also: order numbers get a random suffix (prevents guessing, motivated by
a considered-and-deferred guest order-lookup feature); the discount code
field only shows in the cart when a code is actually active (codes now
apply via a ?code= link instead of manual entry); and three navigation
gaps found while testing — no reachable login link with an empty cart, no
logout link anywhere, no way back from profile to order history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 08:14:08 +00:00
Marco df05ea5358 Add rate limiting, sliding sessions, email verification, GDPR self-service, order cancellation/returns, and critical-error alerting
Complements Payload's per-account login lockout with per-IP rate limiting
on auth routes; proxy.ts silently refreshes an active customer's session
via Payload's built-in refresh-token endpoint instead of a long-lived
token. Registration now sends a non-blocking email-verification link
(doesn't gate login, since checkout registers and immediately logs in
mid-purchase). /konto/profil gets GDPR export/delete; order detail pages
get self-service cancel/return-request, backed by a Payload hook that
closes a real gap (a customer's JWT could previously PATCH any field of
their own order, not just status). Checkout failures now email an alert
independent of Payload's own health, since Kuma's uptime checks can't see
an order silently failing to persist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 07:28:01 +00:00
Marco 7f37f111e8 Add real order persistence, customer accounts, and cart sync
Checkout now persists orders server-side (Payload orders collection,
re-priced from live product data, discount codes redeemed exactly once)
instead of writing a client-only sessionStorage snapshot. Buying requires
an account (registration inline in checkout, no separate step) — accounts
get order history with delivery status, profile/address editing, password
change, and a cart that syncs across devices while logged in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 06:45:42 +00:00
Marco 06abf1a6ae Add discount code feature (server-validated) and RelatedProducts polish
Discount codes:
- New shared lib/cartTotals.ts (computeSubtotal/computeCartTotals) factored
  out of the previously-triplicated subtotal/totalSavings/total math in
  CartContent/CheckoutContent/BestellbestaetigungContent, extended to also
  fold in a discount amount (percent or fixed, clamped so total can't go
  negative).
- lib/discount.ts mirrors lib/cart.ts's exact localStorage pattern so an
  applied code survives the /cart -> /checkout transition without a second
  input field — Checkout only displays it.
- New /api/discount/validate (read-only check) and /api/discount/redeem
  (re-validates + increments the redemption counter, called once from
  checkout's handlePurchase right before the OrderSnapshot is written).
  Both talk to Payload's new discount-codes collection through
  lib/discountServer.ts, a server-only module kept separate from
  lib/payload.ts on purpose (that file is also imported by "use client"
  components; the RSC-boundary break hit earlier this session was exactly
  this mistake with next/headers).
- OrderSnapshot gains discountCode/discountAmount so /bestellbestaetigung
  displays what was actually applied instead of losing it on recompute.

RelatedProducts: no longer falls back to re-suggesting a product already
in the cart just to pad the grid out to 3 cards — shows only the
genuinely available remainder (down to 1 card), centered in the 12-column
grid instead of left-aligned.
2026-07-21 21:16:17 +00:00
Marco 028a1fc4ec Add active-product-count-driven automation
- Products gain `active`/`spotlight*`/`updatedAt` on the base Product type
  (folded in from the now-removed separate SpotlightProduct type) so shop
  grid, spotlight, and related-products can each filter `.active` from the
  same already-fetched list — cart/checkout/order-confirmation/product-
  detail pages keep resolving any product regardless of active status.
- getSpotlightProduct() now derives from getProducts() instead of its own
  Payload query: with exactly 1 active product, that one IS the spotlight
  (overriding any `spotlight` flag elsewhere); otherwise same
  most-recently-updated tie-break as before, just computed client-side.
- ProductGrid drops the already-dead SHOP_GRID_EXCLUDE_IDS list in favor of
  the same `active` filter, with an empty-state message if 0 active.
- RelatedProducts gates on >=2 active products regardless of cart contents
  or how many display slots would otherwise resolve.
- Navbar's "Shop" link becomes an anchor to the homepage spotlight section
  (id="spotlight") instead of a real /shop navigation whenever exactly 1
  product is active — passed down from the now-async root layout, which
  fetches the catalog once for this decision.
2026-07-21 20:39:22 +00:00
Marco 4f2f137b27 Fix RSC build break: keep next/headers out of lib/payload.ts
payload.ts's mapping functions/types are also imported by "use client"
components (LiveTestimonialsGrid, LivePostContent) — importing
next/headers anywhere in that module made it unbundlable for the client,
breaking the production build. draftMode() is now only ever called in the
Server Component pages themselves; they pass the resulting boolean into
getPostBySlug/getLegalPage/getTestimonials as a plain `draft` option.
2026-07-21 19:21:10 +00:00
Marco 26ae4a15f4 Wire testimonials CMS collection and Payload Live Preview
Testimonials on /todo-cards, /newsletter, /challenge now come from the new
Payload testimonials collection via a shared TestimonialsGrid component,
instead of 3 separately hardcoded arrays.

Adds Next.js Draft Mode (/api/preview) plus Live-Preview-aware client
wrappers (LiveRichText, LiveTestimonialsGrid, LivePostContent) for posts,
legal pages, and testimonials — mounted only while Draft Mode is enabled,
so ordinary visitors keep getting the plain static components.
2026-07-21 19:02:20 +00:00
Marco 2d6cff9f40 feat(blog): optional quote label + swappable related-product card per post
Both were fully hardcoded before: every blockquote showed a static
"Merke dir:" label, and every post's "Passend dazu" card always linked
the same flagship product. Now driven by two new Posts fields —
quoteLabel (empty hides the label/icon/underline, blockquote still
renders) and relatedProduct (empty hides the card entirely) — mirroring
Products.spotlight but per-post instead of a single site-wide flag.

README's collection table updated to match today's Payload changes
(shipping-settings, the new Posts fields, trust-badges' placeholder
tokens, admin sidebar grouping) — also fixed a stale claim that `media`
isn't tenant-scoped; it already was.
2026-07-21 13:10:06 +00:00
Marco 225a8567a9 feat(shipping): move delivery-time settings to Payload, polish product/cart CTAs
The delivery-time range (handling + transit days) was a hardcoded
HANDLING_DAYS/TRANSIT_DAYS_DE pair in lib/shipping.ts — changing it
needed a code deploy. Now sourced from Payload's new Shipping Settings
collection via getShippingSettings(), threaded down as a prop to the
few Client Components (Cart/Checkout/VersandModal) that can't fetch it
themselves, with the old code constants removed.

Also: the delivery-time note is now shown on every purchase CTA (shop
grid, home spotlight, ToDo-Karten hero + pricing panel), not just one
of them — required next to each buy button per Art. 246a §1 Abs.1
Nr.8 EGBGB, not just somewhere reachable via a link. Checkout's
sidebar was missing the "ab 39€ kostenlos" note Cart already had;
that's fixed too, and both now show the delivery-time range on its own
line instead of crammed onto the shipping-cost line.

Related smaller fixes bundled in since they touch the same files:
price/delivery-time spacing tightened into its own group, the
redundant "Sichere Zahlung" note under Cart's checkout button (already
shown via the trustBadges list right below) replaced with "Sichere
SSL-Verschlüsselung" to match Checkout, and ToDo-Karten's pricing panel
no longer shows a premature payment-security note at the add-to-cart
step.
2026-07-21 12:44:35 +00:00
Marco 819f87cdeb fix(bestellbestaetigung): rebuild testimonial band, tighten hero, add spotlightEyebrow
Testimonial band: rebuilt as a proper flexbox two-column layout
(photo column with an explicit width, quote in a flex-1 sibling)
instead of an absolutely-positioned text block offset with
percentage margin/padding — that measured against the row's full
width and, combined with a max-w- on the text box, left almost no
room for the actual text on wide viewports (wrapped to one word per
line). Also dropped the muted-bg box behind the quote and widened
the photo fade to match the actual mockup, which has no separate
colored panel there at all.

Hero: removed the separate big checkmark badge — the 4-step bar
right above it already renders every step as a checkmark, so it was
just repeating that. Added more top spacing to compensate. Dropped
"Deine Bestellung macht sich jetzt auf den Weg zu dir." and added
"inkl. MwSt." under Gesamtbetrag for consistency with /cart and
/checkout.

Wired the new Products.spotlightEyebrow field (CMS-editable "Neu im
Shop" label) through lib/payload.ts into ProductSpotlight.tsx.
2026-07-20 00:18:47 +00:00
Marco 90be4696af feat: add /bestellbestaetigung order confirmation page
Matches the Figma mockup (checkmark hero, order summary card,
delivery-status panel, testimonial band) with the checkout's 4-step
bar inserted (all steps done) and the "Bis dahin: Lass dich
inspirieren" block omitted, per request.

Extracted the step bar into a shared CheckoutSteps component so
/checkout and /bestellbestaetigung don't duplicate it. The actually-
selected shipping cost and payment method are captured into a
sessionStorage snapshot by /checkout's "Jetzt kaufen" click (there's
no real order backend, so this click is what "placing the order"
means here) and read back on the confirmation page — not just
defaulted to the first active method of each, so the receipt matches
what the shopper actually picked. Also tightened the checkout
newsletter-consent copy ("Wenn du zustimmst" instead of "Wenn du
oben zustimmst").
2026-07-19 23:57:03 +00:00
Marco 7b35b3e55b feat(shop): show sale badge and strikethrough price on discounted products
Products with compareAtPrice set now show a "-XX%" badge over the
image plus a struck-through original price, wherever price is
displayed (shop grid, homepage spotlight, cart line items). Also
switched the "inkl. MwSt. zzgl. Versand" rows on Pricing.tsx and
ProductSpotlight from items-center to items-baseline — with a large
price next to small disclosure text, center alignment left the
small text visibly floating above the price's bottom edge.
2026-07-19 23:31:35 +00:00
Marco d472eb546f Move trust badges, shipping/payment methods, Werkzeuge cards, product spotlight into CMS
New Payload collections, all editable without a code deploy:
- TrustBadges: the horizontal Schneller-Versand/Versandkostenfrei/Mit-
  Liebe-verpackt row (TrustRow.tsx, now an async server component).
- CartTrustBadges: the Sichere-Zahlung/14-Tage-Rückgaberecht/Nachhaltig-
  verpackt sidebar bullets — shared by /cart (title only) and /checkout
  (title + description), which previously had two different hardcoded
  bullet lists for what's conceptually the same content.
- ShippingMethods: /checkout's Versandart radios. Each method has its own
  optional freeShippingThreshold — omitted means "never free" (Express),
  not "always free". /cart's FreeShippingBanner now targets the lowest
  threshold among active methods instead of a single global constant, and
  hides entirely if no active method has one.
- PaymentMethods: /checkout's Zahlungsart radios, icons as an array
  (Kreditkarte shows 3 logos, PayPal/Überweisung show 1).
- Products: new spotlight/spotlightHeadline/spotlightText/spotlightImage/
  compareAtPrice fields. ProductSpotlight.tsx (homepage) now shows
  whichever product has `spotlight` checked instead of being hardcoded to
  ToDo-Karten, with its own marketing copy separate from the plain
  catalog name/description. AddToCartButton takes an explicit productId
  prop now instead of a hardcoded "todo-karten" constant.
- WerkzeugeCards: the homepage's "Meine Werkzeuge" 3-card grid (Tools.tsx,
  now async). Icons use a uniform box instead of the previous per-card
  hand-tuned width/height/rotation, which only worked for 3 known,
  upside-down-authored SVGs — those were re-exported as pre-flipped PNGs.

lib/payload.ts gained getTrustBadges/getCartTrustBadges/getShippingMethods/
getPaymentMethods/getWerkzeugeCards/getSpotlightProduct, all with the same
graceful-empty-array-on-fetch-failure pattern as the existing functions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 23:15:46 +00:00
Marco 8bfbf74693 Add featured-post flag, align tool-page connector arrows, misc blog polish
- Posts collection: new "featured" checkbox (Payload) — /blog now prefers
  a manually-featured post over just the most recent one. Query sorts by
  "-featured,-publishedAt" so multiple accidentally-featured posts
  resolve deterministically (most recent among them wins) instead of
  erroring or being ambiguous.
- challenge/page.tsx: step-connector arrow now uses the same
  /icon-arrow-connector.svg asset as todo-cards/newsletter's HowItWorks,
  replacing its own hand-drawn inline SVG.
- todo-cards & newsletter HowItWorks: connector arrows now vertically
  centered on their icon (md:mt-[1.625rem]), matching Challenge's
  margin-based centering technique instead of sitting flush at the row's
  top edge.
- Blog overview: featured card overlaps the header slightly (-mt-8) per
  Figma-inspired refinement, and now also shows the publish date in its
  meta row (previously only the list items did).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 21:24:04 +00:00
Marco ed58a65b8f Add blog overview page (/blog), link Navbar "Blog" to it
- app/blog/page.tsx: header (title/subheading + bleeding photo with a
  left-edge fade, per Figma node 4577:330), featured-post card (most
  recent), remaining posts as a divided list with category/readTime/date,
  and the reusable Newsletter signup panel. Uses all real posts from
  Payload — the Figma mockup padded its list out to 4 items with two
  posts that were never actually seeded, so this only renders what
  exists (currently 1 featured + 2 listed).
- lib/payload.ts: BlogPost now carries publishedAt (needed for the list's
  per-post date), removed the now-redundant duplicate field on PostDetail.
- Navbar: "Blog" now links to /blog instead of scrolling to the
  homepage's #blog anchor — same real-route pattern as "Shop".
- blog/[slug]/page.tsx: earlier title-size tuning (fluid clamp between
  text-h-feature and text-display) from this session's follow-up
  feedback, not yet pushed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 21:04:10 +00:00
Marco 2b270cf299 Add blog detail page (/blog/[slug])
- app/blog/[slug]/page.tsx: article header (category/readTime, Playfair
  display title, excerpt, byline), full-bleed hero image, RichText body,
  "Passend dazu: ToDo-Karten" cross-promo, author bio card, "Weiterlesen"
  related-post card. Widths, fonts, and the Passend-dazu card's
  border/padding/spacing were corrected against the actual built Figma
  frame (page-blog-detail, node 4667:344, jCCZyh1DGwdjpv1wGge9To) via
  get_design_context after a few visually-wrong guesses.
- RichText.tsx: new "quote" case renders Lexical's blockquote feature as
  the "Merke dir:" pull-quote — label+underline+divider+Caveat lines,
  using the real exported sparkle/underline assets, matching Figma's
  actual (background-less) structure instead of a guessed bordered card.
- lib/payload.ts: getPostBySlug() for single-post fetches.
- lib/format.ts: formatDate() — "03. Juli 2025"-style German dates.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 20:51:41 +00:00
Marco 7a3ae0ea53 Add /widerruf and /agb legal pages, fix newsletter modal scroll jank
- New /widerruf page: CMS-driven content via getLegalPage("widerruf"),
  sidebar TOC + "Nachhaltig handeln" callout, TrustRow, and a download
  card for the Muster-Widerrufsformular PDF attachment.
- New /agb page, same legal-page shell pattern as Impressum/Datenschutz.
- lib/payload.ts: getLegalPage now also resolves the optional `attachment`
  media relation (depth=1) for legal pages that have a downloadable file.
- NewsletterModal: replaced the body overflow/position scroll-lock hacks
  (which fought with the sticky Navbar and the global scroll-smooth CSS)
  with a wheel/touch event interceptor that never touches scroll position
  at all, plus `preventScroll: true` on all modal/drawer focus() calls to
  stop the browser's implicit scrollIntoView from nudging the page for
  elements near the sticky navbar's reserved scroll-padding-top zone.
  Same preventScroll fix applied to VersandModal and the Navbar mobile
  drawer for consistency.
- globals.css: scrollbar-gutter: stable, so a modal's overflow:hidden
  lock never causes a scrollbar-width layout shift.
- Divider: stagger its reveal animation slightly (delay 0.3s) behind the
  section above it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 19:57:28 +00:00
Marco 1b434fe23e Add /impressum and /datenschutz pages backed by Payload
- lib/payload.ts: getLegalPage(type) alongside the existing
  getBlogPosts/getProducts fetchers
- New RichText component: small dependency-free Lexical JSON → JSX
  renderer for Payload's richText fields (headings get stable
  "section-N" ids for anchor/TOC linking)
- New SectionTOC: generalized from VersandTOC into a reusable
  scroll-spy sidebar driven by any {id, title}[] — Datenschutz needs one
  built from CMS-authored headings, not a hardcoded array. VersandTOC is
  now a thin wrapper around it. Sticky positioning moved from the nav
  itself to each page's sidebar wrapper, so a TOC and an extra card
  (Impressum/Datenschutz both have one) scroll together as one unit
  instead of the card drifting away independently
- TOC clicks set the active item immediately and hold it for ~1s over
  the scroll-spy observer, covering both "target already fully visible,
  nothing to scroll" and "an unrelated section flickers through the
  intersection band mid-scroll"
- Both pages follow the same breadcrumb+H1+TOC-sidebar+content layout
  established for /versand
2026-07-19 17:38:02 +00:00
Marco 8c397c5dcb Move product catalog to Payload CMS
Products now come from Payload's new "products" collection instead of a
hardcoded catalog, same pattern already used for blog posts:

- lib/payload.ts: getProducts()/getProductBySlug() (server-side fetch,
  60s ISR)
- New /api/products route so client components (CartContent,
  RelatedProducts) can reach the same data without a server-only import
- lib/products.ts: useProducts() hook replacing the old PRODUCTS record
- ProductGrid (/shop) fetches server-side directly; now shows all
  catalog products except notizbuch-klarheit (matches Figma's 4-card
  page-shop-overview — still cross-sold via RelatedProducts)
- ProductSpotlight and /todo-cards' Pricing now pull price/photo from
  the same CMS product instead of a separately hardcoded "12,90 €", so
  the two can't silently drift apart
- formatPrice moved to a new lib/format.ts (plain, no "use client") —
  Server Components can't call functions exported from a "use client"
  module directly, which lib/products.ts now is because of the hook

Also fixes two unrelated bugs surfaced along the way: the add-to-cart
button visibly resizing when its "Hinzugefügt ✓" success state showed
(fixed with a CSS-grid text stack sized to the wider of the two
strings), and removes the now-unused local product images from public/.
2026-07-19 17:00:07 +00:00
Marco 9e5532be63 Add shop, cart, versand pages and Impulse & Tipps detail page
- New /shop overview, /cart (real cart state via useSyncExternalStore),
  /versand (shipping policy page with TOC) and /newsletter detail page
- Cart: quantity/removal, order summary, related-products cross-sell
  with randomized picks, VersandModal quick-reference instead of
  navigating away, MwSt. disclosure next to unit prices
- Add-to-cart UX: inline success feedback (green state) plus a
  fly-to-navbar-cart-icon animation (CartFlyProvider) with a delayed
  badge count-up; AddToCartButton no longer navigates straight to /cart
- Shared lib/products.ts catalog and lib/shipping.ts constants (cost,
  free-shipping threshold, handling/transit days) so cart, trust badges
  and the versand page can never drift apart
- Fix Navbar smooth-scroll easing (ease-out instead of ease-in-out, no
  more perceived start delay); compress newsletter modal photo
  2.4MB -> 85KB to fix first-open jank
2026-07-19 14:42:22 +00:00